关于 paddle-customer-portal
这项技能介绍如何在 Next.js 15 应用中接入 Paddle 托管的客户自助门户,面向需要订阅管理功能的开发者。内容涵盖经身份验证的 Server Action、临时门户 URL、安全检查及订阅深层链接。
主要功能
- 01配置 Next.js 15 Server Action
- 02经身份验证查询客户
- 03创建临时门户会话 URL
- 04生成订阅专属深层链接
- 05安全与错误处理说明
适用场景
- 01添加“管理订阅”按钮
- 02让客户查看发票和付款选项
- 03直接链接到取消或付款更新页面
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ paddlehq/ paddle-agent-skills --skill paddle-customer-portal安装后在 Claude Code 中输入/paddle-customer-portal确认已加载。
详细介绍
从 Next.js 创建 Mint Paddle 客户门户会话 URL
何时使用此技能
当你希望 由 Paddle 托管 UI,让客户自行查看发票、更新付款方式、取消订阅等,而你只想让用户通过应用中的“管理订阅”按钮跳转到该托管 UI 时,请使用此技能。本技能介绍如何在 Next.js 15(App Router)中使用 Server Action 验证用户身份、查找其 Paddle 客户 ID、调用 paddle.customerPortalSessions.create,并返回用于重定向用户的 URL。
这是 门户 方案。另一种方案是直接使用 Paddle API 构建自定义账单页面——请参阅下文“是否应该使用门户?”了解两者的取舍。
是否应该使用门户?
Paddle 客户门户是让用户管理订阅的 一种选择。另一种选择是构建自己的账单 UI,直接调用 Paddle Node SDK。两种方式各有适用场景:
| 选项 | 适用场景 |
|---|---|
| Paddle 客户门户(本技能) | 你希望尽快实现“用户可以自行管理订阅”。Paddle 会提供 UI、处理本地化和布局,并随着功能推出持续更新。如果你已设置 Paddle Retain 取消流程,客户取消自动收款的订阅时,门户可以显示这些流程。适合早期产品、MVP,或任何账单功能并非差异化优势的产品。 |
自定义账单页面(参见 subscription-cancel、subscription-update,以及用于列出交易、下载发票等操作的直接 API 调用) | 你需要全面控制品牌呈现、与应用设计系统深度集成、自定义流程(应用内升级提示、取消流程中自定义的挽留逻辑、自定义发票展示),或与非账单 UI 紧密结合。Paddle Next.js starter kit 采用了这种方式——其仪表板以原生方式呈现订阅和交易,并使用 Server Action 执行取消和更新操作。 |
你也可以 混合使用:使用门户处理不常见的管理操作(下载发票、更改付款方式),并为高价值流程(升级,以及显示按比例计算费用的确认信息)构建自定义 UI。如果希望嵌入门户会话 URL,而不是将用户带到概览页面,它还提供了针对特定订阅操作的深层链接。
这个选择并非一成不变。常见的演进方式是:先上线门户,让客户尽快能够自助服务;之后随着特定流程对产品变得越来越重要,再逐步用自定义 UI 替换这些流程。
前提条件
- 一个至少有一条客户记录的 Paddle 账户(沙盒环境也可以)。
- 可在服务端使用的
PADDLE_API_KEY——此操作在 Server Action 中运行,绝不会在浏览器中运行。该密钥需要具备customer_portal_session.write权限。 - 一个通过 Webhook 同步的
customers表(请参阅subscription-sync)。你需要用户的 Paddlecustomer_id来创建会话,并通过邮箱关联进行查询。 - 可选:一个
subscriptions表——如果需要按订阅创建深层链接,请将该客户拥有的订阅 ID 传递给 SDK。 - 一个身份验证系统。示例使用 Supabase,但任何基于会话的身份验证都适用。
NEXT_PUBLIC_PADDLE_ENV=sandbox # or "production"
PADDLE_API_KEY=pdl_sdbx_apikey_...
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=... # or SUPABASE_SECRET_KEY (new opaque sb_secret_*)
Supabase 辅助函数
此操作会使用两个服务端 Supabase 客户端。这些客户端与 Paddle Next.js 入门套件中的客户端一致;如果你的项目已有等效的辅助函数,请改用它们。
// src/utils/supabase/server.ts — the signed-in user's session (anon key + cookies)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component. Safe to ignore if middleware refreshes sessions.
}
},
},
},
);
}
// src/utils/supabase/server-internal.ts — service role, no user session. Server-only.
import { createServerClient } from "@supabase/ssr";
export async function createClient() {
// No cookies: with the signed-in user's session attached, requests would run
// as that user and row-level security (RLS) would still apply.
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => [], setAll: () => {} } },
);
}
服务角色客户端会绕过 RLS,因此只能在服务端、确认用户身份后使用。它会有意跳过 Cookie,因此绝不会获取已登录用户的会话。如果你自行编写辅助函数,请不要将封装函数命名为 createServerClient——这是它调用的 @supabase/ssr 函数名。
门户会话的工作方式
const session = await paddle.customerPortalSessions.create(
customerId, // "ctm_01h..."
subscriptionIds, // string[] — e.g. ["sub_01h...", "sub_02h..."], or [] for overview only
);
返回的 session 提供:
session.urls.general.overview— “打开我的门户”的主链接。 对于标准自助服务流程,应将用户重定向到此链接。用户会进入门户主页,并可从那里进行导航。session.urls.subscriptions[]— 按订阅划分的深层链接数组,每个传入的subscriptionId对应一个条目:id— 与输入的订阅 ID 相匹配。cancelSubscription— 直接进入对应订阅取消界面的链接。updateSubscriptionPaymentMethod— 直接进入对应订阅付款方式界面的链接。view_subscription(API 字段)— 指向门户中该订阅页面的链接,不会预先打开任何表单。Node SDK(3.10.0)尚未提供此字段;如有需要,请直接调用POST /customers/{customer_id}/portal-sessions。
subscriptionIds 最多可包含 25 个不重复的 ID,且每个 ID 都必须属于该客户,否则 API 会返回 400。若只需要概览链接,请传入空数组:门户仍可用于访问账户和发票,但不会提供按订阅划分的深层链接。
这些 URL 具有时效性,并会过期。请将其视为敏感信息:任何持有链接的人都能在链接过期前打开该客户的门户。不要缓存或存储这些 URL——每次用户点击“管理”时,都要创建一个新会话。
完整的 Server Action
// src/actions/portal.ts
"use server";
import { ApiError } from "@paddle/paddle-node-sdk";
import { getPaddleInstance } from "@/utils/paddle/get-paddle-instance";
import { createClient } from "@/utils/supabase/server";
import { createClient as createAdminClient } from "@/utils/supabase/server-internal";
export async function createPortalSession() {
// 1. Authenticate. Reject anonymous requests before any DB query or SDK call.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !user.email) {
return { error: "Not authenticated" };
}
// 2. Look up the authenticated user's Paddle customer_id via the email
// bridge. The customers table is the mirror your webhook handler
// populates from customer.created / customer.updated events.
const admin = await createAdminClient();
const { data: customerRow } = await admin
.from("customers")
.select("customer_id")
.eq("email", user.email)
.single();
if (!customerRow?.customer_id) {
// The user signed up but hasn't completed a checkout yet, so no Paddle
// customer record exists. Don't pass an empty string to the SDK — it
// will 400. Either redirect them to a "subscribe first" page or return
// a clear error the UI can render.
return { error: "No Paddle customer" };
}
// 3. Mint the session. Notice the customerId comes from step 2 (the
// authenticated user's record), NOT from any input parameter — a user
// must never be able to request a portal session for someone else's ID.
// This action only needs the overview URL, so it passes no subscription
// IDs. For deep links, pass up to 25 IDs this customer owns.
try {
const paddle = getPaddleInstance();
const session = await paddle.customerPortalSessions.create(customerRow.customer_id, []);
// 4. Return ONLY the URL. Don't leak the raw session object — it includes
// the customer_id, session id, and full deep-link table that the client
// doesn't need for a "redirect to portal" flow.
return { url: session.urls.general.overview };
} catch (error) {
// 5. Turn Paddle API errors into a message the UI can show; rethrow anything else.
if (error instanceof ApiError) {
console.error("Portal session failed", error.code, error.detail);
return { error: "Couldn't open the billing portal. Please try again." };
}
throw error;
}
}
调用该操作的客户端组件很简单:
// src/components/manage-subscription-button.tsx
"use client";
import { createPortalSession } from "@/actions/portal";
export function ManageSubscriptionButton() {
return (
<button
onClick={async () => {
const result = await createPortalSession();
if ("error" in result) {
// surface the error to the user
return;
}
window.location.href = result.url;
}}
>
Manage subscription
</button>
);
}
安全模型
有三点需要注意:
- 首先进行身份验证。 在任何数据库查询或 SDK 调用之前,操作必须检查会话。门户 URL 是访问客户账单数据的钥匙——为未经身份验证的请求创建 URL 是严重错误。
customerId必须来自已验证的用户,而不是输入。 操作不接受customerId参数(也不接受任何会影响为哪个客户创建门户的客户端提供的标识符)。customerId会根据身份验证会话,通过 customers 表在服务端解析。这样可防止已验证的用户请求“ctm_01h-someone-else 的门户 URL”。- 只返回 URL。 完整的
session对象包含customerId、会话 ID 和深层链接表。客户端只需要重定向 URL——只返回该 URL。
常见陷阱
- 不检查身份验证。 Server Actions 可以从任何地方调用,包括匿名调用、脚本调用和直接 POST。若不进行身份验证,任何人都能生成门户 URL。
- 信任操作输入中的
customerId。 任何客户端传入customerId、操作直接使用该值的模式都是安全漏洞——已通过身份验证的用户可能会为任何他们知道 ID 的客户请求门户。 - 缓存 URL。 门户链接会过期,因此缓存的链接最终会失效;在失效之前,任何拿到存储链接的人都能打开该客户的账单页面。每次点击都应生成新的会话。
- 返回原始会话对象。 这会泄露客户端不需要的
customer_id、会话元数据和每个订阅的深层链接。只返回urls.general.overview(如果确实需要用于嵌入的深层链接,则返回特定 URL——绝不要返回整个会话)。 - 未处理没有客户的情况。 尚未结账的新用户没有 Paddle
customer_id。向customerPortalSessions.create传入空字符串或 null 会返回 400,并附带令人困惑的错误。应明确检测客户信息缺失,并提示“请先订阅”。 - 传入未经检查的订阅 ID。 ID 超过 25 个、存在重复项,或 ID 属于其他客户,都会导致整个请求失败并返回 400。只传入已通过身份验证的客户对应行中的 ID;若要获取概览链接,则不传 ID。
- 混淆“打开门户”和“通过门户取消”。
urls.general.overview是门户的_首页_——用户可以在其中进行门户支持的任何操作。如果想让用户_直接_进入取消界面(针对特定订阅),请改用urls.subscriptions[i].cancelSubscription;如果要打开该订阅的页面但不显示表单,则使用view_subscription。大多数应用只使用概览页面。 - 该用自定义界面时却用了门户,或反之。 选择门户还是自定义界面是实际的产品决策(见上文“是否应该使用门户?”),而不只是图省事的编码捷径。如果想在取消时加入挽留步骤,设置好 Paddle Retain 取消流程后,门户可以显示这些流程(仅适用于自动收集的订阅);若要实现除此之外的挽留逻辑,则应自行构建。如果没有品牌或流程方面的要求,在有此类需求之前,门户是合适的选择。
验证集成
- 以拥有 Paddle 客户记录的已登录用户身份操作(即已完成沙盒结账的用户),点击“管理订阅”。该操作应返回
{ url: '<some https url>' }。访问该 URL 会打开门户首页。 - 确认每次点击生成的 URL 都不同——
urls.general.overview的值每次都应不同。如果没有变化,说明你缓存了会话。 - 在已登出状态下尝试调用该操作。确认它返回
{ error: 'Not authenticated' },且未调用 SDK。 - 以全新用户身份尝试调用该操作(未完成结账,且
customers中没有对应行)。确认它返回{ error: 'No Paddle customer' },且未调用 SDK。 - (如果你为该操作添加了输入参数——不要这样做;但如果出于其他原因确实添加了——)确认该操作无法为已通过身份验证的用户不拥有的客户生成门户 URL。
- 如果你为深层链接传入订阅 ID,请直接检查
customerPortalSessions.create的响应。确认session.urls.subscriptions中为你传入的每个 ID 都包含一项,其中有可用于其他位置深层链接的cancelSubscription和updateSubscriptionPaymentMethodURL。
相关文档
- 客户门户概览
customerPortalSessions.createAPI 参考- Paddle Node SDK 参考
- 参考实现(采用 自定义 页面方案,而非门户):paddle-nextjs-starter-kit — 请参阅
src/app/dashboard/subscriptions/和src/app/dashboard/payments/。