关于 portaly-payment
面向协助用户完成 Portaly Payment 集成的 AI Agent,提供从 API 密钥准备到结账回调处理的操作指引。内容涵盖测试与生产模式差异、订阅生命周期接口及回调签名验证。
主要功能
- 01配置商户与订阅计划
- 02创建结账会话并处理回调
- 03说明测试与生产模式差异
- 04订阅取消、恢复及门户接口
- 05按项目技术栈验证回调签名
适用场景
- 01为现有应用接入 Portaly 结账
- 02测试支付回调处理逻辑
- 03接入订阅取消与恢复流程
- 04排查 API 密钥模式和回调签名
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ portaly-ai/ portaly-skills --skill portaly-payment安装后在 Claude Code 中输入/portaly-payment确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/api-contract.mdcallback-signature-v1-vectors.jsoncallback-signature-v1.mdcheckout-and-renewal.mdconversion-tracking.mddiscount-code-examples.mdscripts/check_callback_vectors.mjsgo.modsign_callback.mjssign_callback.pysign_callback.webcrypto.mjsverify_callback_test.goverify_callback.go详细介绍
Portaly 支付集成
使用此技能帮助用户快速完成 Portaly Payment API 集成。回答应注重实操:优先采用步骤列表、API 请求和响应要点,以及可直接复制的示例,而不是冗长的架构说明。
Portaly Payment 环境
每个 API 密钥支持两种模式:live 和 test。
API 主机和支付网站
两种模式的 API 主机和支付网站(买家会被重定向到该网站进行结账)都使用同一个统一域名:
https://portaly.ai(默认)
可通过 PORTALY_API_HOST 环境变量覆盖主机。生成调用 Portaly API 的代码时,优先采用以下模式,而不是将 URL 硬编码:
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
有关后端兼容性约定,请参阅仓库根目录下的 PROVIDER.md。
模式行为
| 方面 | 实时模式 | 测试模式 |
|---|---|---|
| API 密钥前缀 | pcs_live_ | pcs_test_ |
| 支付提供方 | 91APP — 买家会被重定向离开,并在其回调中完成扣款 | TapPay 沙盒 — 在结账页面本身完成扣款 |
| 订单存储 | 创建者的实时订单账本 | 独立的沙盒账本 |
| 回调载荷 | mode: "live" 或缺省 | mode: "test" |
- 模式在创建 API 密钥时设定,创建后无法更改。
- 同一个商户(
profileId)可以同时启用一个实时密钥和一个测试密钥。 - 除订单退款接口外,API 端点同时接受实时密钥和测试密钥:
POST /orders/{orderId}/refund目前要求使用实时模式的全权限密钥。模式根据密钥确定,而非请求参数。 - 测试模式用于集成测试。使用 TapPay 沙盒凭据时,测试模式不会产生真实扣款。
- 切勿编造卡号。 测试模式的结账页面会在银行卡字段正下方的醒目框中显示要使用的测试卡号——请告诉用户从页面上读取。该卡号只适用于此集成创建的
checkoutUrl;在任何真实扣款场景中都会被拒绝,并返回原始网关错误,而非友好的错误提示。 - 测试模式和实时模式使用不同的支付提供方。 测试结账通过 TapPay 在结账页面本身完成扣款;实时结账则将买家转到 91APP,并在其回调中完成。因此,测试成功并不代表已测试实时模式的重定向与返回流程;回调中的
paymentMethod在测试模式下为tappay,在实时模式下为91app——不要将其硬编码。也不要把这两个值当作仅有的可能值:通过POST /checkout-sessions/{sessionId}/complete完成的会话会携带商户发送的任何paymentMethod(且不会关联订阅——参见 §6)。 - 测试模式下的订阅永远不会续订。 续订任务会直接跳过测试订阅,因此无论用户等待多久,都不会收到第二周期的
creator_subscription.payment.succeeded。请通过重放载荷来测试续订处理程序,而不是等待时间推移。 - **若要在测试模式下无需等待
cancelEffectiveAt就看到creator_subscription.canceled,请从 Orders 表中退款测试订单(工具栏切换到 Test):订阅会立即取消,并发送canceled。测试退款不会发送payment.refunded,因此该处理程序仍需通过重放载荷进行测试。 - 沙盒账本不在结算链路中。 测试前请告知创建者,以免他们去查找本来就不该出现的记录:测试订单不会计入收入、余额或打款,不会产生联盟或促销佣金,不会开具发票,也无法进行评价(因此不会发送评价邀请)。将订单表中的 Live/Test 切换为 Test 后,这些订单会显示在
https://portaly.cc/admin/creator-subscription中,也可以在此退款——请将这个检查方式告诉创建者。不要称之为“测试选项卡”:页面的选项卡是 Subscriptions 和 Orders,模式切换按钮位于表格自身的工具栏中。 - 方案和商户配置在不同模式间共用。 它们属于商户(
profileId),而非 API 密钥模式。使用实时密钥创建的方案对测试密钥可见且可用,反之亦然。切换实时密钥和测试密钥时,不要创建重复方案——请先通过GET /api/creator-subscription/plans查询现有方案并复用。
快速入门
前提条件——必须拥有 Portaly Payment 账户。 此集成需要 Portaly Payment API 密钥。如果用户还没有 Portaly Payment 账户,请先停止操作,并引导他们前往
https://portaly.cc/payment注册,然后再继续——可以提出为他们打开页面,并在他们同意后打开:# 打开 Portaly Payment 注册页面——根据用户的操作系统选择对应命令 Start-Process "https://portaly.cc/payment" # Windows (PowerShell) open "https://portaly.cc/payment" # macOS xdg-open "https://portaly.cc/payment" # Linux在用户拥有账户并创建密钥之前,不要继续操作。注册后,用户可在
https://portaly.cc/admin/creator-subscription的控制面板中创建密钥。
- 开始前,AI Agent 应询问用户是否要在 Portaly Payment 控制面板
https://portaly.cc/admin/creator-subscription申领或创建 Portaly Payment API 密钥/CallbackSecret,并安全保管发放的密钥材料。 - 询问用户要使用正式密钥还是测试密钥,并明确说明限制:正式密钥需要同时满足以下三项条件——商户(即个人)已通过 Portaly 付款验证(金流审查 / KYC,否则返回
403 PAYMENT_KYC_NOT_VERIFIED);拥有付费会员资格(Portaly premium 或 Portaly Vibe 订阅,否则返回403 PREMIUM_REQUIRED);并且,从第二个产品开始,该产品必须通过自身的审核(否则返回403 PRODUCT_REVIEW_NOT_VERIFIED——个人身份已经验证,无需重新提交;待审核的只有该产品)。这三项限制均由服务端强制执行,并非只是在控制面板界面中隐藏选项。首次使用的用户几乎都还未通过验证,因此无法使用正式密钥。建议先使用测试密钥,立即构建并测试完整集成;满足全部三项条件后,用户可以返回控制面板创建正式密钥,用于生产环境。
- 确认用户想要构建什么。
为支付集成任务做好准备,例如:
- 创建商户配置
- 创建订阅方案
- 上传商户或方案图片(如有需要,Agent 应请用户提供图片素材)
- 完成设置后,将结账会话创建和回调处理集成到当前系统中:
- 买家发起付款前创建结账会话
- 将买家重定向到 Portaly 结账页面
- 结账完成后,验证并处理来自 Portaly 的回调
- 如果集成需要管理订阅生命周期,还应为定期方案接入取消和恢复 API
- 如果集成需要订阅者自助服务(让订阅者自行管理订阅),还应接入门户会话 API
- 从
references/api-contract.md开始。 使用该文档查看端点列表、身份验证、请求正文、响应正文和回调标头。 - 在生成或修复回调接收器之前,先检查代码库使用的语言、框架和运行时,然后加载
references/callback-signature-v1.md。 选择匹配的内置适配器,并运行源自生产环境的测试向量。对于未列出的运行时,请使用文档所述的服务端桥接方案;或者在原生实现通过测试向量之前暂停集成;切勿凭记忆改写 Node 签名器。 - 仅在需要时加载
references/checkout-and-renewal.md。 仅当用户询问结账后扣款、续订、付款、发票或桥接订单行为时,才将其用作补充参考。 - 返回可直接用于实现的内容。 优先采用编号步骤、API 端点列表、请求和响应要点,以及符合代码库现有技术栈的示例。
输出风格
- 面向协助人类用户完成集成工作的 AI Agent 编写。
- 首先说明人类用户接下来应采取的具体步骤。
- 明确指出,Agent 可使用 Portaly Payment API 密钥直接调用 API。
- 如果用户已提供有效凭据和所需输入,优先直接使用设置 API 配置商户、创建计划、更新计划、上传图片和创建结账会话。
- 以下内容使用列表:
- 设置步骤
- API 端点
- 必需的请求头
- 请求字段
- 响应字段
- 回调验证步骤
- 如果没有指定技术栈,常规 API 示例优先使用简洁的 JavaScript 或 TypeScript。对于回调验证,先检查或询问技术栈,并遵循
references/callback-signature-v1.md;不要默认使用 JavaScript。 - 明确区分 Portaly 负责的行为和第三方负责的行为。
工作流
1. 申请 API 密钥
-
此集成需要 Portaly Payment API 密钥和 CallbackSecret。
-
指引用户前往 Portaly Payment Dashboard,网址为
https://portaly.cc/admin/creator-subscription,申请或创建 Portaly Payment API 密钥。 -
询问用户需要 正式 密钥(
pcs_live_…)还是 测试 密钥(pcs_test_…),并提前说明以下门槛,避免他们遇到问题:- 正式密钥需要先通过 Portaly 支付验证(金流审核 / KYC)。 商家完成验证之前,Dashboard 只会提供 Test 选项,并说明 Live 选项不可用的原因;商家可通过该页面上的「金流审核」入口开始验证。服务端也会强制执行此要求,因此无法绕过 Dashboard:创建密钥会返回
403 PAYMENT_KYC_NOT_VERIFIED。 - 正式密钥还需要付费会员资格——Portaly premium 或 Portaly Vibe 订阅(
403 PREMIUM_REQUIRED)。免费方案即使通过验证也不够;用户需要先升级。 - 从商家的第二个产品开始,每个产品都要单独审核(需要提供该产品自己的服务 URL 和业务描述)。如果用户已通过身份验证,但这个产品尚未通过审核,创建密钥会返回
403 PRODUCT_REVIEW_NOT_VERIFIED。不要让他们重新进行身份验证——这一步已经完成;他们需要在 Dashboard(Payment > 金流審核)提交此产品进行审核并等待批准。 - 首次安装的用户通常尚未通过验证,因此无法使用正式密钥。请告知他们这是预期情况,并非错误。
- 建议先使用测试密钥(
pcs_test_…)——用户可以立即通过 TapPay 沙箱测试集成的大部分功能(配置、方案、结账、回调),且不会产生真实扣款,但并非全部功能。有关测试模式下成功运行仍无法验证的内容,请参阅上面的测试模式要点:续订、正式环境下 91APP 的重定向和返回、开票及结算。通过支付验证后,用户可返回 Dashboard,创建正式密钥,并将PORTALY_API_KEY替换为pcs_live_…值以用于生产环境。无需更改代码——模式由密钥决定。
- 正式密钥需要先通过 Portaly 支付验证(金流审核 / KYC)。 商家完成验证之前,Dashboard 只会提供 Test 选项,并说明 Live 选项不可用的原因;商家可通过该页面上的「金流审核」入口开始验证。服务端也会强制执行此要求,因此无法绕过 Dashboard:创建密钥会返回
-
明确说明,此步骤由人工操作员在 Portaly Payment Dashboard 中完成,不由第三方集成代码执行。
-
告知用户妥善保存签发的密钥材料;若代表用户保存,只能使用适当的密钥管理器或安全环境存储。
-
说明 API 密钥用于 API 调用中的 bearer 身份验证,
callbackSecret用于验证来自 Portaly 的回调真实性(如果用户询问)。 -
切勿要求用户将 API 密钥或
callbackSecret粘贴到聊天中。 聊天记录可能会被记录、缓存,或由模型在摘要、差异或工具调用参数中回显。应将密钥视为 Agent 无需以明文查看的值。 -
应改为指导用户自行通过编辑器或 shell 将密钥写入
.env,使用以下模板:PORTALY_API_KEY=pcs_live_xxx # or pcs_test_xxx for test mode PORTALY_CALLBACK_SECRET=xxx -
Agent 会在运行时通过
process.env.PORTALY_API_KEY(Node)或os.environ["PORTALY_API_KEY"](Python)读取这些值——不需要在上下文中出现密钥明文。 -
如果项目使用密钥管理器(1Password CLI、Doppler、AWS/GCP Secrets Manager、Vault 等),应优先使用它,而非
.env。 -
继续操作之前,确认
.gitignore已包含.env。 如果.gitignore不存在或未包含.env,请立即创建或更新。绝不允许将凭据提交到版本控制中。 -
如果用户误将密钥粘贴到聊天中,建议他们在使用前先到 Portaly Payment Dashboard 轮换密钥——应假设粘贴的值已泄露。
1.5 报告已安装技能的版本
向 Portaly 报告此技能的版本,以便商家的控制面板在已安装的技能过时时发出提示。这是一次单独的非阻塞版本检查——请求正文只包含技能名称和版本,不包含用户项目或数据的任何信息。向用户提及一次(例如:“正在报告已安装的 Portaly 技能版本,以便您的控制面板检测过时的技能”)——无需暂停等待批准,但也不要隐瞒此事。
发送时机——每个会话发送一次;只要设置了 process.env.PORTALY_API_KEY,以下情况中先发生的那个时机即可发送:
- 安装或更新此技能后立即发送,前提是环境中已有密钥。
- 否则,在首次进行任何 Portaly API 调用时发送(配置商家、列出套餐、创建结账会话等)——版本报告会随首次实际 API 使用一同发送。
- 如果环境中尚未设置
PORTALY_API_KEY,暂时跳过报告(情况 2 会在之后处理)。切勿仅为了发送报告而向用户索要密钥。 - 请求:
POST https://portaly.ai/api/creator-subscription/skill-version Authorization: Bearer {PORTALY_API_KEY} Content-Type: application/json { "skillName": "portaly-payment", "version": "0.18.2" } version是本文件顶部 frontmatter 中此技能的metadata.version——请使用当前运行的 SKILL.md 中的字面值,确保报告反映实际安装的版本。- 请求正文只包含
skillName和version。如果调用失败,忽略它并继续——它绝不会阻塞任何操作。 - 成功后,检查响应中的
data.available数组——其中包含此商家账户从未报告过版本的技能,每项的格式为{ id, installPackage, latestVersion, description }。这不等于尚未安装:只有当技能自身的版本报告触发时,服务器才会知道该技能存在;这发生在首次实际使用时,而不是运行npx skills add时。因此,已经存在于此项目中但尚未使用的技能仍会出现在此列表中。在提及任何内容之前,请自行根据实际项目进行筛选:移除技能目录或文件已在本地存在的条目——服务器看不到项目树,只有你能看到。如果筛选后没有剩余条目,就什么也不说。对于剩余条目,在本次会话中只向用户提及一次,说明他们可以添加这些技能(npx skills add ...),绝不要说成是他们缺少的技能——列出每个技能的名称及其一句话description。这只是发现功能——除非用户要求,否则不要自行运行npx skills add。 - 如果此项目中安装了多个 Portaly 技能,每个技能都会发送自己的版本报告,并收到相同的
available列表——每个会话只展示一次,不要每个技能各展示一次。如果你或其他 Portaly 技能已在本次会话中展示过available列表,则在此跳过此步骤。 - 如果
data.available缺失、为空,或响应格式不符合预期,则不执行任何操作——与调用失败时一样,忽略并继续;这绝不能成为阻塞任何操作的步骤。
2. 配置商家设置
- Agent 应使用 Portaly Payment API 密钥直接通过 API 调用执行这些设置操作。
- 当用户需要在任何产品上线前设置商家品牌信息时,请使用 Config API。
- AI Agent 应要求用户提供
merchantLogo图片素材,并使用配置图片上传 API 将图片上传到 Portaly。商家 Logo 为可选项——如果用户暂时没有准备好,可跳过此步骤并继续创建套餐。 - 使用
PUT /api/creator-subscription/config和POST /api/creator-subscription/config/images,通过 Portaly Payment API 密钥设置商家品牌信息。
3. 创建有效的订阅计划
- Agent 应直接使用 Portaly Payment API 密钥,通过 API 调用创建计划、更新计划和上传计划图片。
- **创建新计划前,务必使用当前 API 密钥,通过
GET /api/creator-subscription/plans查询现有计划。**计划在正式模式和测试模式之间共享;如果已有合适的计划,应重复使用,而不是创建重复计划。 - 创建结账会话前,Portaly 中至少要有一个有效计划。只为
status为active的计划显示付款按钮;已归档(inactive)计划的结账会话会被拒绝,并返回422 PLAN_INACTIVE。请将其处理为友好的“此计划已不再提供”状态,而不是通用付款错误——请参阅references/api-contract.md中 Session Creation 下的 Error responses 表格。 - 使用 Plan API 创建或更新人工用户希望在 Portaly 上列出的产品基本信息。
- 确认计划名称、描述、金额、货币、计费周期(
monthly、yearly或one-time)、定价类型(fixed或dynamic)和状态都符合预期产品。 - 新建计划会在结账时要求买家提供手机号码(
collectPhone默认值为true),否则无法完成付款。付款完成后的回调中会返回customerPhone,且此号码会显示在创作者的订单列表和导出数据中。询问人工用户是否需要此功能;如果不需要,请创建计划时设置collectPhone: false,或稍后通过PUT /plans/{planId}将其关闭。2026-09 之前创建的计划默认不会要求提供手机号码,除非创作者主动开启。 - 年付计划采用 12 个月的延期拨款方式:买家预先支付全年费用,但创作者的款项会分 12 个月发放(每月发放净收入的 1/12)。年付订单的第一期款项发放后,退款将被禁止。创建年付计划前,请向人工用户说明这一权衡:这种方式可以控制创作者的退款风险,但意味着从该时间点起,买家无法获得任何退款。
- 对于动态定价计划:将
pricingType设为dynamic,并将billingPeriod设为one-time。计划中不设置金额;调用方创建每个结账会话时传入amount。 - 如果第三方有自己的产品目录,请将 Portaly 的
planId与商家的内部产品或权益标识符一并保存。 - AI Agent 应请人工用户提供计划图片,并使用计划图片上传 API 将图片上传到 Portaly。
- 将 Portaly 返回的
checkoutUrl视为权威地址。不要根据猜测的域名自行拼接。 - 创建或更新计划后,检查响应中的
name和description是否存在乱码(mojibake)。如果有乱码,请修复 shell 编码,并使用PUT /api/creator-subscription/plans/{planId}更正。请参阅 Guardrails 中的 Windows 编码说明。
3.5 创建折扣码(可选)
- 至少创建一个方案后,使用折扣码 API。
- 一个代码包含一组规则;每条规则可针对不同的方案设置各自的折扣和时长。例如,代码
EARLYBIRD有两条规则——月度方案打 5 折,持续 3 个周期(= 3 个月);一次性方案减 NT$200,持续 1 个周期。年度代码示例请参见ANNUAL20:年度方案打 8 折,持续 1 个周期(= 1 年)。 - 请针对每条规则向用户确认:
- 折扣类型:
fixed(减免 TWD)/percent(按百分比折扣)/free(100% 折扣)。 - 时长:
repeating N cycles(重复 N 个周期,默认为 1)或forever(永久,通常与fixed搭配)。一个周期等于一个计费周期——月度方案为一个月,年度方案为一年。 - appliesTo:
all(针对任何未被特定规则覆盖的方案的兜底规则)或specificplanIds(例如,仅针对年度方案)。每个代码最多只能有一条all规则;同一 planId 不能出现在多条规则中。
- 折扣类型:
- 代码级参数:
- 自定义代码:3–40 个字符,
[A-Z0-9_-]。存储和显示时均为大写;查找时不区分输入大小写。每个 profile 唯一。创建后不可更改。 - 兑换时间范围:
redeemFrom/redeemBy。 - 数量上限:
maxRedemptions(总数)/maxRedemptionsPerCustomer(每个邮箱)。
- 自定义代码:3–40 个字符,
- 代码在正式和测试模式间通用(与方案相同)。
- Portaly 不会替买家选择代码:只有在结账会话中传入
discountCode,或买家在托管结账页面输入代码时,折扣才会生效。对于推荐链接,请在自己的应用中将代码关联到用户,并在结账时传入(参见references/discount-code-examples.md)。 - 示例提示词和参数速查表请参见
references/discount-code-examples.md。 - 资金变动保护:创建正式模式折扣时,需要用户明确确认(与创建正式模式方案的规则相同)。
4. 创建结账会话
- 在买家发起付款之前创建结账会话。
- 使用
Authorization: Bearer {api_key}调用POST /api/creator-subscription/checkout-sessions。 - 发送
planId,以及可选的successRedirectUrl、cancelRedirectUrl、callbackUrl、subscriptionCallbackUrl、merchantOrderNumber和以字符串为键的metadata。 - 如果买家已登录商家自己的产品,请不要让他们重新输入任何信息:发送
customerEmail+customerName以预填结账表单,并发送emailVerified: true,声明商家已验证该邮箱,这样就会跳过通过邮件发送的验证码。emailVerified仅接受通过 API 密钥认证的此调用传入——绝不能由买家的浏览器传入;如果customerEmail为空白或未提供,该字段会被忽略。结账时邮箱字段将变为只读。完整字段规则请参见references/api-contract.md。- 自定义
metadata键会被回显到已签名的回调正文中。请将自己的数据放在唯一的tracking键下,并以 JSON 字符串形式传入——metadata: { tracking: JSON.stringify({ … }) }——所有适配器都会验证此格式。Python 和 Go v1 适配器会拒绝任何不在已提交架构中的其他键(v1 使用 JavaScriptlocaleCompare对键排序,这些适配器无法针对任意键复现此排序),也会拒绝tracking下的对象;其内部键也会以相同方式检查,因此请将其序列化为字符串。对于其他任何自定义键,请使用 Node/WebCrypto 接收端。
- 自定义
callbackUrl会接收checkout.completed回调;除非设置了subscriptionCallbackUrl,否则也会接收定期续订(payment.succeeded/payment.failed)和生命周期回调。设置subscriptionCallbackUrl,即可将续订/生命周期事件路由到专用端点。- 可选的
discountCode:提供后,Portaly 会预先验证并应用折扣。无效代码会返回400 INVALID_DISCOUNT_CODE。 - 可选的
profitSharingId:当产品已开启买家推广时,买家到达时携带的推荐码。请在服务端从自己的 Cookie 中读取并在此传入——绝不能从浏览器请求正文中接收,否则任何人都能认领他人的销售额。未知或不匹配的代码会被忽略,结账仍会完成——但如果没有 Cookie 值,请省略此字段:它必须为 1–64 个字符,因此空字符串会导致400,而不是被静默忽略。推广功能本身的设置属于portaly-affiliate技能的范围。 - 在第三方一侧保存
sessionId、checkoutToken、checkoutUrl和expiresAt。 - 如果商家将进行转化跟踪(步骤 10),现在就将其自己的订单 ID 放入
successRedirectUrl——这是在所有返回路径中唯一能够保留的键,事后添加意味着必须重新签发会话。 - 当你传入的
discountCode已应用时,会话响应会包含appliedDiscount;session.amount始终是买家需要支付的折后金额。 - 将买家重定向到
checkoutUrl。
5. 让 Portaly 运行托管结账
- 从第三方的角度,将 Portaly 托管结账视为黑盒。
- 不要要求第三方收集卡令牌或实现由 Portaly 负责的付款步骤。
6. 处理结果
- 主要的外部确认方式是向
callbackUrl发送已签名的回调。 - 结账时会发送两种回调:首次扣款成功时发送
creator_subscription.checkout.completed,失败时发送creator_subscription.checkout.failed(被拒绝,或买家在 91APP 页面取消)。两种都要处理——如果商家只监听.completed,就无法知道哪些买家支付失败。 - 对于托管结账,只有在 Portaly 写入订阅及其首笔订单后,才会发送
checkout.completed,因此处理程序可以立即使用回调中的sessionId调用GET /subscriptions/{sessionId}、取消或恢复订阅。极少数情况下,如果买家已付款但记录写入失败,仍会发送回调,以便商家知晓这笔付款——若在checkout.completed后立即调用GET /subscriptions/{sessionId}得到404,表示“联系 Portaly 支持”,而不是“重试直到记录出现”。 creator_subscription.checkout.failed包含sessionId、profileId、planId、planName、mode、amount、currency、customerEmail、failureReason、failedAt、metadata。它有意不包含subscriptionId——首次扣款失败意味着从未创建订阅,因此应将sessionId同时用作标识符和幂等键。test模式的会话也会发送此回调(载荷中的mode会注明模式),因此部署处理程序后,沙盒端点会立即开始收到checkout.failed。checkout.failed是结账未完成时唯一会发送的回调:付款尝试失败时会触发,包括买家在 91APP 页面取消的情况(列表端点会将这些记录标记为paymentCanceled: true)。买家关闭标签页或任由会话过期时,不会触发任何回调。使用GET /api/creator-subscription/checkout-sessions?outcome=abandoned,failed批量拉取未完成的结账——仪表板中的「未完成結帳」对应outcome=abandoned(按outcome、startDate/endDate筛选;当pagination.hasMore为true时继续翻页——页面可能不足额,甚至为空);只有在已有sessionId需要检查时,才使用单会话接口GET /api/creator-subscription/checkout-sessions/{sessionId}。若列表项的outcome: "abandoned"且paymentSubmitted: true,表示买家确实已提交付款,但 Portaly 丢失了回调——请对该记录进行对账,不要再次向其营销。- 若要重新发送端点未收到的结账回调:
POST /api/creator-subscription/checkout-sessions/{sessionId}/retry-callback。首次扣款失败时应使用以会话为键的路由;/subscriptions/{id}/retry-callback无法找到该记录,因为订阅不存在。 - 周期续费和退款也会发送已签名的回调(与结账回调使用相同的签名和验证方式):
creator_subscription.payment.succeeded/.failed表示续费扣款的结果;creator_subscription.payment.refunded/.refund_failed表示单笔付款订单的最终结果。退款事件按orderId去重,而不是按subscriptionId。生命周期事件(creator_subscription.active/.cancel_requested/.canceled)也以相同方式发送。根据x-portaly-event标头进行分流。完整的事件表和载荷请参阅references/api-contract.md→ Signed Callback,续费行为请参阅references/checkout-and-renewal.md。 - 仅在用户构建非托管或恢复流程时,才将手动调用
POST /api/creator-subscription/checkout-sessions/{sessionId}/complete用作例外流程。它仅将会话标记为completed/failed、记录折扣码的使用,并发送checkout.completed/checkout.failed。它不会创建任何订阅、付款记录、订单或发票:GET /subscriptions/{sessionId}会返回404,不会发生续费,交易不会计入商家在 Portaly 的收入或结算,也不会开具电子发票。切勿用它来“完成”看似卡住的托管结账——应改为轮询会话。 - 当前实现约定:
subscriptionId === checkoutSessionId === sessionId。 - 周期性结账成功后,用户的系统可以直接将回调中的
sessionId用作subscriptionId,以便后续调用取消或恢复订阅的 API。 - 请明确告知用户,这是 Portaly 当前的实现约定,结账完成后应在其系统中持久化保存该值。
7. 验证并持久化
- 生成代码前,先检查代码库使用的语言、框架、服务端/边缘运行时、请求体解析器和现有验证器。加载
references/callback-signature-v1.md,并选择匹配的 Node、WebCrypto、Python 或 Go 适配器。 - 发布前,针对该运行时运行
scripts/check_callback_vectors.mjs。仅通过自行生成的签名并不足够;预期值来自已提交的 Portaly 生产签名器。 - 要求三个回调标头全部存在。使用
x-portaly-timestamp中的原始 ISO 字符串;若其无效,或与当前时间相差超过五分钟(包括未来或过去),则拒绝。对称时间窗口可以容忍常见的时钟偏差;严格执行“拒绝任何未来时间戳”的规则会导致合法回调间歇性失败。 - 使用 API 密钥的
callbackSecret验证x-portaly-signature,然后要求经过身份验证的请求体中的event与x-portaly-event相等。 - V1 对
stableJson(JSON.parse(wireBody))签名,而不是对原始 HTTP 请求体签名。切勿用码点键排序替代 JavaScriptlocaleCompare语义。 - 验证后,按照应用的数据策略,持久化最少的审计字段:
sessionId、存在时的subscriptionId、merchantOrderNumber、支付标识、事件和状态。切勿记录密钥或完整的签名基串。 - 如果回调载荷不包含
subscriptionId,则将sessionId持久化为周期性订阅标识符,因为当前实现将sessionId用作subscriptionId。 - 按事件类型进行幂等处理:结账完成使用
event + sessionId;续费成功使用event + subscriptionId + chargedAt,续费失败使用event + subscriptionId + failedAt;退款成功/失败使用event + orderId。续费的幂等键不要使用paymentId或paymentReference:payment.failed完全不包含paymentId,而且在几乎所有 91APP 失败情形中,paymentReference都是空字符串,因此无论选择哪个,都会把所有订阅者的每次续费失败合并到同一个键上。chargedAt/failedAt是每次尝试独有的时间戳,在重投时会保持字节完全一致。不要通过sessionId/subscriptionId永久去重所有生命周期事件;当前生命周期载荷没有文档说明的投递标识符,因此应确保状态赋值本身幂等,并将更强的去重要求标记为产品契约缺口。 callbackUrl必须使用 HTTPS。 通过纯 HTTP 服务会导致传输中的callbackSecret签名和载荷暴露。
8. 管理周期性订阅
- 只有
billingPeriod = monthly | yearly的周期性方案支持取消或恢复。 - 取消意味着停止下一次周期性扣款,并非退款。在你的系统中,相关权益或内容应保持有效,直到当前已付费周期结束;订阅记录中的
cancelEffectiveAt会标明该时间。 - 对于年度订阅,取消不会触发未释放递延部分的退款——创作者仍会按照原定的 12 个月计划收到剩余的月度分期款项,购买者也会继续拥有访问权限,直到
cancelEffectiveAt(即不再扣款的下一次年度续订日期)。 - Portaly 目前支持通过经过 API 密钥身份验证的端点,由商家系统发起订阅生命周期操作。
- 这些调用请使用同一个 Portaly Payment API 密钥。
周期性订阅管理 API:
GET /api/creator-subscription/subscriptions— 分页并按条件筛选,列出所有订阅GET /api/creator-subscription/subscriptions/{subscriptionId}POST /api/creator-subscription/subscriptions/{subscriptionId}/cancelPOST /api/creator-subscription/subscriptions/{subscriptionId}/resume
订单查询 API:
GET /api/creator-subscription/orders— 列出付款/订单记录,可按startDate/endDate、status(多个值以逗号分隔)和planId筛选,并支持游标分页。日期筛选条件作用于createdAt;对于这些订单,createdAt === paidAt,因此这是对账期内款项的接口GET /api/creator-subscription/orders/{orderId}— 查询单笔订单的refundRequestedAt、refundedAt、refundFailedAt和refundFailureReason,无需扫描订单列表POST /api/creator-subscription/orders/{orderId}/refund— 使用具有完整权限范围的正式密钥申请全额退款。请求体:{ "reason": "customer_requested", "reasonNote": "optional", "amount": 400 };API 密钥调用方必须提供reason,且amount只能等于订单全额。新请求返回202;已完成或正在处理的请求返回200。测试密钥返回409 TEST_MODE_REFUND_UNSUPPORTED;集成权限范围的密钥返回403 KEY_SCOPE_FORBIDDEN。
收到 202 后,处理 creator_subscription.payment.refunded 或 .refund_failed 事件,并将 GET /orders/{orderId} 作为对账备用方案。TapPay 退款延迟时,可能会在最多三次每日计划重试期间保持待处理状态,因此从收到 202 起,最终结果可能需要约三天才能确定。当两个最终状态时间戳均为 null 时,继续轮询;如果出现 refundFailedAt,或者超过该重试时限仍未出现任何最终结果,请联系 Portaly 支持。退款可能会独立触发 creator_subscription.canceled;事件顺序不作保证,因此请按负载中的时间戳排序,并分别按 subscriptionId 去重取消事件、按 orderId 去重退款结果。
周期性管理规则:
- 这些 API 仅接受
Authorization: Bearer {api_key} - 商家系统集成不得使用 Firebase 身份验证
billingPeriod = one-time不支持取消或恢复- 订阅的
amount是基础价格,不是付款记录。 它在结账时固定,续费时按此金额扣款。享受折扣的订阅会附带discount快照(包含code、appliedRule、startedAt、endsAt(null= 永久)、source,以及在这些字段记录之后创建的订阅所带的originalAmount/finalAmount),在折扣生效期间,实际扣款金额会低于amount。请根据续费回调中的amount或订单记录进行对账,切勿依据订阅的amount。 cancel会将订阅标记为cancelAtPeriodEnd = true- 只有在订阅尚未完全变为
canceled之前,resume才有效
取消请求体:
{
"reason": "customer_requested",
"reasonNote": "optional note"
}
恢复请求体:
{}
周期性订阅生命周期需要持久化的信息:
subscriptionIdsessionIdplanIdbillingPeriodstatuscancelAtPeriodEndcancelEffectiveAt
9. 启用订阅者自助服务门户(可选)
- 商家希望订阅者直接管理自己的订阅时,可使用此功能。
- 商家后端通过
https://portaly.ai上的POST /api/creator-subscription/portal-sessions创建门户会话,然后将订阅者重定向到返回的portalUrl。 - 这是服务器到服务器调用,绝不能向客户端公开 API 密钥。
- 订阅者会进入 Portaly 托管的门户页面,并通过会话令牌完成身份验证。无需额外登录。
- 订阅者可在门户中查看订阅、取消或恢复订阅,以及查看付款历史记录。
- 门户会话在 30 分钟后过期。
- 商家必须提供
returnUrl,以便订阅者管理完订阅后返回。 - 完整的端点详情和代码示例,请参阅
references/api-contract.md中的Portal Session (Subscriber Self-Service)。
10. 接入转化跟踪(可选)
- 当商家询问 GA4、Google Ads、Meta Pixel、UTM 归因,或“我能在支付页面上放置分析工具吗”时,使用此内容。
- Portaly 不会将商家提供的 GA4、GTM 或 Meta Pixel 标签注入托管结账页面——而且即使在页面上添加标签,也无法满足商家的需求:
utm_*参数已在商家自己的网站上被读取,不会出现在portaly.ai上;Meta 的_fbcCookie 属于商家自己的域名,Portaly 无法读取;续费、退款和扣款失败根本不会在浏览器中发生。请明确说明这一点,让商家知道该采取什么措施,而不是以为这是缺失的功能。 - 商家通常询问的四项数据中,已有三项掌握在自己手中:
merchantOrderNumber和planId是他们自行选择的值,amount/currency会在回调中返回。只有广告系列归因需要处理,而且完全由他们自行负责。 - **商家从未想到的必需设置:**将
portaly.ai添加到 GA4 的不需要的引荐来源网址列表中(Admin → Data collection and modification → Data streams → Web → Configure tag settings → Show all → List unwanted referrals)。否则,重启后的会话会被归因到portaly.ai / referral,广告系列信息也会丢失。每次都要先说明这一点。 - 在商家自己的成功页面上触发
purchase:由于来源相同,_gaCookie 和会话的广告系列信息都会保留,GA4 无需额外操作即可正确归因。无需跨域 linker——这正是因为支付页面没有运行商家的标签。这是准确的路径,但并不完整:买家付款后必须从 Portaly 点击跳转才能到达该页面,因此关闭标签页的人不会触发该事件。务必将其与回调配合使用。创建会话时,将你自己的订单 ID 放入successRedirectUrl;在此处通过该 ID 查找订单,并从该记录中获取其他所有值——Portaly 会附加自己的参数,但哪些参数会出现因支付路径而异,因此任何参数都不能视为固定约定。 - 对于未返回成功页面的买家,以及续费和退款(这些事件客户端标签永远无法获知),请从已签名的回调在服务器端触发事件。回调中的
customerEmail可直接作为 Meta Conversions API 的匹配键,无需额外配置——但必须进行哈希处理:em字段需要经过 SHA-256 处理的地址,且地址必须先去除首尾空格并转换为小写,绝不能发送明文。 - **区分两种 ID。**你自己的幂等键是第 7 步中的键——在重复投递时保持稳定,每次事件发生对应一个。Meta 的
event_id用于让 Meta 将服务器端事件和浏览器事件识别为同一笔购买——而浏览器事件只会在首次结账时发生——因此应使用从商家自己的订单记录中读取的sessionId。每笔扣款都使用各自的event_id(payment.succeeded中的paymentId):Meta 要求每个事件实例使用唯一 ID;而subscriptionId === sessionId,意味着重复使用sessionId会让每次续费都使用相同的值。 - 续费的幂等键应使用每次尝试各自的时间戳。
payment.failed不包含paymentId,而 91APP 几乎所有失败事件的paymentReference都是空字符串——使用其中任一项都会把所有订阅者的所有续费失败合并为同一个键,导致催款流程悄无声息地停止。请使用event + subscriptionId + failedAt,而对于payment.succeeded则使用chargedAt;不要使用会在成功后重置的failureCount。(payment.succeeded确实包含可用的paymentId——只有失败事件没有。) - **续费时,
merchantOrderNumber是个陷阱。**它确实存在,但在结账时就已固定,因此将其用作 GA4 的transaction_id会导致 GA4 将首次续费之后的所有续费都去重。请为每笔扣款生成独立的 ID——而且绝不要发送空的transaction_id,否则所有购买都会被合并为一笔。 - 广告标识符应存入商家自己的存储中,或放在
metadata的tracking下——绝不能将其用作metadata的独立键。在用户首次访问时捕获utm_*/gclid/fbclid,并使用gtag('get', …)读取 GA4 的 ID,而不是解析_ga_*Cookie(Google 没有记录其格式,并在 2025 年更改过格式)。然后,可以将记录关联到创建会话后返回的sessionId,并让回调通过它进行关联(merchantOrderNumber是可选的,而且checkout.failed中不会包含它,因此不是可靠的关联键);也可以发送metadata: { tracking: JSON.stringify({ utm_source, gclid, … }) }——tracking是为商家自有数据保留的键,已纳入回调向量,因此只要其值是字符串,就能通过所有适配器的校验(若值为对象,其中的键会接受校验,校验失败时会拒绝请求);并且由于该列表约束的是键而非值,里面的 JSON 字符串可以携带任意结构,无需扩展列表。对于订阅,优先选择存储并关联的方式:在订阅的整个生命周期中,每次续费和生命周期事件都会重放metadata。在成功页面上,通过 URL 中你自己的订单 ID 查找记录。 - 绝不要将广告标识符作为独立键放入
metadata。clientId、client_id、fbp、fbc、session_id、utm_source、gclid和fbclid都不在已提交的签名白名单中,因此使用其中任意一个,都会导致 Python 或 Go 接收器对该订阅的回调返回 401;而且每次续费和退款都会重放metadata,所以在该订阅的整个生命周期中,对账都会停止。campaign和source这类宽泛标签则在白名单中,可以安全使用。 - 加载
references/conversion-tracking.md,查看会话持续条件及其对应的 Google 来源、成功页面代码片段、各事件的 ID 表,以及哪些metadata键确实安全。
建议的回复结构
使用此技能回答时,优先按以下顺序组织:
- 目标摘要
- 人工设置步骤
- API 列表
- 请求字段
- 响应字段
- 回调处理步骤
- 示例代码
- 故障排查说明
防护措施
- 开发时默认使用测试模式。 如果加载的密钥以
pcs_live_开头,在进行任何 API 调用前,先向用户确认是否确实要使用实时模式。切勿在用户不知情的情况下对生产计费环境执行操作。 - 涉及资金转移的操作必须获得用户明确确认。 调用以下任何操作前,说明具体操作、目标(
subscriptionId/sessionId)以及模式(实时 / 测试),然后等待用户回复“是”:POST /subscriptions/{id}/cancelPOST /subscriptions/{id}/resumePOST /checkout-sessions/{id}/complete(手动完成)- 实时模式下的任何计划创建 / 更新操作
- 不要在多个订阅上批量执行或循环执行这些操作;每项操作都必须单独获得确认。
- 尽可能优先使用托管结账流程。该流程已处理电子邮件验证、支付方式持久化、回调分发、订阅创建、支付创建、发票任务创建和订单桥接写入。
- 明确区分:
- Agent 可直接使用 Portaly Payment API 密钥调用的设置 API
- 不要臆测支付提供商的行为。TapPay 和 91APP 存在实质性差异。
- 不要仅凭收到回调就认定操作成功;必须检查
status和经过验证的签名。 - 不要仅凭重定向成功页面推断订阅状态。重定向仅用于用户体验;回调或状态查询才是事实来源。
- 将
references/checkout-and-renewal.md视为非 API 背景资料。仅当任务明确涉及定期计费、打款、发票跟进或桥接订单行为时才加载。 - Windows 编码: 在 Windows 上,进行包含非 ASCII 文本的 API 调用前,运行
chcp 65001(cmd)或$OutputEncoding = [System.Text.Encoding]::UTF8(PowerShell)。如果计划的name或description返回乱码,请修正编码并PUT正确的值。 - 速率限制: 所有创作者订阅 API 端点(
POST /checkout-sessions除外)均受速率限制。读取端点每分钟允许 120 次请求,写入端点每分钟允许 20 次请求。如果收到429响应,请使用Retry-After标头安排重试。对大型结果集分页时,请留意速率限制额度。
交付内容
使用此技能时,目标是返回以下一项或多项内容:
- 面向用户的精简分步集成计划
- 相关 API 的扁平列表
- 请求和响应字段明细
- 使用用户技术栈编写的回调验证代码
curl、fetch或 TypeScript 示例代码- 按会话状态分类的故障排查列表
资源
references/api-contract.md用于了解 Bearer 身份验证、端点契约、回调标头、负载字段以及第三方实现形式。references/checkout-and-renewal.md仅作为结账生命周期和续订行为的高层背景资料。references/discount-code-examples.mdDiscount Code API 的示例提示词、参数速查表和推荐链接处理方式。references/callback-signature-v1.md运行时路由、精确的 v1 契约、安全的处理程序顺序、失败时关闭的边界和诊断指南。references/conversion-tracking.md结账重定向期间的 GA4 / Meta 转化跟踪:为什么支付页面不携带商家标签、必需的非预期引荐设置、成功页面和回调端事件,以及 Measurement Protocol / Conversions API 说明。references/callback-signature-v1-vectors.json使用已提交的生产契约生成签名的合成负载。请使用这些数据,而不要使用自行签名和自行验证的测试样例。scripts/check_callback_vectors.mjs针对已提交的正向、负向和失败时关闭案例,运行选定的 Node、WebCrypto、Python 或 Go 适配器。scripts/sign_callback.py用于已提交回调密钥域的 Python 适配器;对于任意元数据键和不受支持的数字,该适配器会失败并关闭。scripts/sign_callback.mjsNode.js、JavaScript、TypeScript、Express 或 Next.js 集成优先使用此文件。scripts/sign_callback.webcrypto.mjs用于无法导入node:crypto的边缘 / WebCrypto 运行时(Cloudflare/Vercel Edge、Deno、InsForge 边缘函数)。使用相同方案和字节级一致的stableJson;通过全局crypto.subtle验证。scripts/verify_callback.go和scripts/verify_callback_test.goGo 适配器及其基于生产环境契约和失败时关闭行为的测试。../portaly-affiliate/SKILL.md当商家希望自己的买家推荐他人并赚取佣金时,请阅读此文件。它基于此处设置的套餐和结账会话,并在会话创建中增加一个字段。