PayPal 集成
PayPal 在欧美、拉美地区有大量的存量用户,很多人习惯用 PayPal 余额或绑定的账户付款,而不愿意在陌生网站上输入信用卡号。把 PayPal 作为 Stripe 之外的补充支付渠道,可以接住这部分订单。
NEXTY.DEV 的 PayPal 集成支持两种售卖形态:
- 一次性积分包:定价页直接渲染 PayPal 按钮,在当前页面完成付款,不跳转。
- 周期订阅:点击后跳转到 PayPal 的授权页面,授权成功后回到网站。
本章介绍 PayPal 平台侧的配置步骤,以及如何把拿到的凭证填进模板。
提示:
- PayPal 是可选集成。如果你只用 Stripe 收款,可以跳过本章。
- PayPal 和 Stripe 可以同时启用,每个定价计划由
provider字段决定走哪个渠道。
注册与准备
-
注册一个 PayPal Business 账号(个人账号无法创建订阅产品,也无法收取商业款项)。
-
用这个账号登录 PayPal Developer Dashboard。开发者后台和商家后台是两套界面,本章绝大部分操作在开发者后台完成。
注意:
开发者后台左上角有 Sandbox / Live 切换。开发阶段全程待在 Sandbox,等支付流程调通后再把同样的步骤在 Live 模式重做一遍。Sandbox 和 Live 的凭证、Webhook、订阅计划完全独立,不能复用。
创建 App 并获取凭证
- 进入 Apps & Credentials 页面,确认处于 Sandbox 模式,点击 Create App,类型选择 Merchant。

- 创建完成后进入 App 详情页,可以看到 Client ID 和 Secret key(Secret 需要点击 Show 才会显示)。

- 把它们填进环境变量:
# PayPal 总开关,由全局的 PayPalProvider 读取
NEXT_PUBLIC_ENABLE_PAYPAL=true
# 开发阶段填 sandbox,上线后改为 live
NEXT_PUBLIC_PAYPAL_ENVIRONMENT=sandbox
NEXT_PUBLIC_PAYPAL_CLIENT_ID=你的_Client_ID
PAYPAL_CLIENT_SECRET=你的_Secret_key值得注意的是:
NEXT_PUBLIC_PAYPAL_ENVIRONMENT决定了模板请求哪个 API 域名(api-m.sandbox.paypal.com或api-m.paypal.com),也决定了模板从定价配置里读test还是live的计划 ID。这个值和你填的 Client ID 必须来自同一套环境,否则会报鉴权失败。
创建 Sandbox 测试账号
Sandbox 模式下不能用你自己的真实 PayPal 账号付款,需要 PayPal 生成的测试账号。
进入 Testing Tools - Sandbox Accounts 页面,PayPal 默认已经给你生成了一个 Business 账号(收款方)和一个 Personal 账号(付款方)。点击 Personal 账号的 ⋮ - View/Edit account,可以看到登录邮箱,密码可以在这里重置为你记得住的值。

后面测试支付时,就用这个 Personal 账号登录 PayPal 付款页面。
创建 Webhook
支付结果、退款、订阅续费全部依赖 Webhook 落库,这一步不能省。
- 回到 Apps & Credentials,点击刚才创建的 App,下拉到 Webhooks 区域,点击 Add Webhook。

- 填写 Webhook URL:
- 本地开发填 内网穿透地址 + api 路径(即
<your_forwarded_address>/api/paypal/webhook) - 生产环境填 生产环境地址 + api 路径(即
<your_server_address>/api/paypal/webhook)
内网穿透地址的获取方式和 Stripe 那一章完全一样:在 Cursor 或 VSCode 中设置 Forward Port,端口填本地服务启动端口,切换成 Public,复制生成的 Forwarded Address。

注意:
PayPal 不接受
http://localhost作为 Webhook URL,必须是公网可访问的 https 地址。如果你暂时不想折腾内网穿透,可以先跳过本地 Webhook 调试,见下文「本地联调」。
- 勾选事件类型。模板实现了以下 15 个事件的处理逻辑,建议全部勾选:
一次性付款(积分包)
- PAYMENT.CAPTURE.COMPLETED
- PAYMENT.CAPTURE.PENDING
- PAYMENT.CAPTURE.DENIED
- PAYMENT.CAPTURE.DECLINED
- PAYMENT.CAPTURE.REFUNDED
- PAYMENT.CAPTURE.REVERSED
订阅扣款
- PAYMENT.SALE.COMPLETED
- PAYMENT.SALE.REFUNDED
- PAYMENT.SALE.REVERSED
订阅生命周期
- BILLING.SUBSCRIPTION.ACTIVATED
- BILLING.SUBSCRIPTION.UPDATED
- BILLING.SUBSCRIPTION.SUSPENDED
- BILLING.SUBSCRIPTION.PAYMENT.FAILED
- BILLING.SUBSCRIPTION.CANCELLED
- BILLING.SUBSCRIPTION.EXPIRED未订阅的事件类型(例如 CHECKOUT.ORDER.APPROVED)模板会直接静默 ack,不会报错,所以多勾也没有副作用。
- 保存后,在 Webhook 列表里可以看到这个 Webhook 的 ID,复制到环境变量:
PAYPAL_WEBHOOK_ID=你的_Webhook_ID注意:
PAYPAL_WEBHOOK_ID是验签的必要参数。PayPal 的验签方式和 Stripe 不同:它不是本地用密钥算 HMAC,而是把请求头里的签名信息连同这个 Webhook ID 一起发回给 PayPal 的verify-webhook-signature接口做校验。所以这个值填错或者没填,所有 Webhook 都会被拒绝。
定价计划写在代码里
在配置具体的售卖形态之前,先明确一点:NEXTY.DEV v4 的定价计划是配置即代码,全部写在 config/pricing.ts,不存数据库、也没有后台 CRUD。每个计划用 provider 字段声明由谁收款,PayPal 的计划就写 provider: 'paypal'。
一次性积分包和周期订阅在 PayPal 这边的准备工作完全不同:
| 一次性积分包 | 周期订阅 | |
|---|---|---|
| PayPal 平台侧 | 不需要创建任何东西 | 需要创建 Product 和 Plan |
| 定价配置 | 只填 price + currency | 还要填 paypalPlanId |
| 前端形态 | 定价卡片内嵌 PayPal 按钮,弹窗付款 | 跳转 PayPal 授权页,授权后回跳 |
注意:
已经卖出去过的计划不要删除、不要改
id,下架请改成active: false。历史订单和续费账单都靠这个 slug 反查计划,删掉会导致 Webhook 处理抛错,PayPal 会连续重试好几天。
配置一次性付款(积分包)
一次性付款不需要在 PayPal 后台创建产品或价格。用户点击按钮时,模板后端会拿 config/pricing.ts 里的金额通过 Orders API 动态开一笔订单,所以这一步只改代码:
{
id: 'pack-mini-paypal', // 稳定的 slug,订单和积分流水永久引用它
kind: 'credit_pack', // 积分包,不是订阅
credits: 300, // 付款成功后发放的积分
provider: 'paypal',
price: 5.9, // 扣款金额,以这里为准
currency: 'USD',
active: true,
copy: { en: { name: 'Mini Pack', description: '...', features: ['...'] } },
}注意这里没有 paypalPlanId —— 积分包不需要,填了也没用。
配好之后,定价卡片会自动把 CTA 换成 PayPal 官方按钮(条件是 provider: 'paypal' 且 kind: 'credit_pack',并且配置了 NEXT_PUBLIC_PAYPAL_CLIENT_ID)。完整链路是:
用户点击 PayPal 按钮
→ POST /api/paypal/create-order 后端按配置金额创建订单
→ PayPal 弹窗内完成付款(不离开当前页面)
→ POST /api/paypal/capture-order 后端 capture 并立即发放积分
→ 跳转 /payment/success提示:
- 金额一律以服务端配置为准,前端传过来的价格不采信,所以不用担心被改价。
currency会用来初始化 PayPal JS SDK,必须是 PayPal 支持的币种,并且要和你商家账号能收的币种匹配。- 模板默认关闭了信用卡直付(
disableFunding: "card"),只走 PayPal 账户付款。想放开的话改lib/paypal/script-options.ts。- 积分包只能在个人工作区购买,团队工作区下单会被拦截并提示切换工作区。
- 用户如果用 eCheck 之类的方式付款,capture 会返回 PENDING,这时钱没到账,模板只记一条待处理订单、不发积分,等
PAYMENT.CAPTURE.COMPLETED事件到达再补发。
配置周期订阅:创建产品与计划
订阅和积分包相反,必须先在 PayPal 侧建好计划才能卖。
PayPal 的订阅由两层对象组成:Product(产品) 和 Plan(计划,含价格和周期),对应关系类似 Stripe 的 Product 和 Price。
方式一:在商家后台创建
-
Sandbox 环境:用上一步的 Sandbox Business 测试账号登录 sandbox.paypal.com;Live 环境:用你的真实商家账号登录 paypal.com。
-
进入 Pay & Get Paid - Subscriptions,创建产品。



- 为产品创建计划,填写金额、币种、计费周期。

- 创建完成后可以看到计划 ID,格式形如
P-5ML4271244454362WXNWU5NQ。

方式二:用 API 创建
部分国家和地区的 PayPal 后台没有 Subscriptions 菜单,这时候只能走 API。
先用 Client ID 和 Secret 换取 access token:
curl -v -X POST "https://api-m.sandbox.paypal.com/v1/oauth2/token" \
-u "CLIENT_ID:CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials"创建产品:
curl -v -X POST "https://api-m.sandbox.paypal.com/v1/catalogs/products" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro Plan",
"description": "Nexty Pro subscription",
"type": "SERVICE",
"category": "SOFTWARE"
}'用返回的 id 创建计划:
curl -v -X POST "https://api-m.sandbox.paypal.com/v1/billing/plans" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_id": "PROD-XXXXXXXXXXXX",
"name": "Pro Monthly",
"status": "ACTIVE",
"billing_cycles": [
{
"frequency": { "interval_unit": "MONTH", "interval_count": 1 },
"tenure_type": "REGULAR",
"sequence": 1,
"total_cycles": 0,
"pricing_scheme": {
"fixed_price": { "value": "29.90", "currency_code": "USD" }
}
}
],
"payment_preferences": {
"auto_bill_outstanding": true,
"setup_fee_failure_action": "CONTINUE",
"payment_failure_threshold": 3
}
}'返回里的 id(P- 开头)就是你要的计划 ID。
提示:
total_cycles: 0表示无限循环扣款,这是订阅制想要的行为。- 计划创建后价格不可随意修改,调价要新建计划。这一点和 Stripe 的 Price 一样,所以定价决策要尽量想清楚再建。
- 计划状态必须是
ACTIVE,CREATED状态的计划无法用于下单。
把计划 ID 填进定价配置
拿到 P- 开头的计划 ID 后,回到 config/pricing.ts,给订阅计划填上 paypalPlanId:
{
id: 'pro-monthly-paypal', // 稳定的 slug,订单和积分流水永久引用它
kind: 'subscription',
interval: 'month',
monthlyCredits: 2000, // 每次扣款成功后重置到这个额度
provider: 'paypal',
paypalPlanId: {
test: 'P-REPLACE_paypal_sandbox', // Sandbox 计划 ID
live: 'P-REPLACE_paypal_live', // Live 计划 ID
},
price: 29.9, // 展示价格,必须和 PayPal 计划里的金额一致
currency: 'USD',
active: true,
copy: { en: { name: 'Pro', description: '...', features: ['...'] } },
}注意:
- 和积分包相反,订阅计划的
price只用于卡片展示,真正扣款的金额以 PayPal 计划为准。两边写得不一致,用户看到的和扣到的就是两个数,务必核对。test/live由NEXT_PUBLIC_PAYPAL_ENVIRONMENT决定读哪个,两边都要填。
更多定价配置的说明见支付系统文档。
本地联调
两条链路分开验,都用 Sandbox 的 Personal 测试账号付款。
一次性付款
- 打开定价页,确认积分包卡片的 CTA 变成了 PayPal 官方按钮。按钮没出来,先查
NEXT_PUBLIC_PAYPAL_CLIENT_ID有没有配、计划的provider和kind对不对。 - 点击按钮,在弹窗里用测试账号付款。付完不会离开定价页,而是由前端调用 capture 接口后跳到
/payment/success。 - 检查数据库的订单记录和用户积分余额,确认金额和积分数与配置一致。
周期订阅
- 点击订阅计划的按钮,确认跳到了 PayPal 的授权页面(地址形如
sandbox.paypal.com/webapps/billing/subscriptions?ba_token=...)。跳不过去一般是paypalPlanId填错或计划不是ACTIVE状态。 - 授权后回跳到成功页,检查订阅记录的状态和当期积分。
- 续费不用等真实周期,用下面的脚本模拟。
Webhook
模板在 NODE_ENV=development 时会跳过签名验证(PayPal 无法给 localhost 投递可验签的真实事件),因此本地可以直接把事件 POST 到 /api/paypal/webhook 来测试。模板提供了一个订阅续费的模拟脚本:
# 先改脚本里 CONFIG 段的 subscriptionId / userId / planId 等字段
node scripts/test-paypal-renewal.mjs
# 也可以用命令行参数覆盖
node scripts/test-paypal-renewal.mjs --saleId=SALE-123 --amount=9.99注意:
PayPal 的 Webhook 按事件 ID 幂等,重复用同一个
saleId触发,服务端会判定为已处理并跳过。测试多次记得换 ID。
切换到 Live 模式
Sandbox 调通后,Live 模式需要把整套步骤重做一遍。可以把下面的清单当做 Checklist:
- 开发者后台切到 Live,创建 Live App,拿到新的 Client ID 和 Secret。
- 更新环境变量:
NEXT_PUBLIC_PAYPAL_ENVIRONMENT=live,换上 Live 的NEXT_PUBLIC_PAYPAL_CLIENT_ID和PAYPAL_CLIENT_SECRET。 - 在 Live App 下重新创建 Webhook,URL 指向生产域名,事件勾选同上,把新的 Webhook ID 填进
PAYPAL_WEBHOOK_ID。 - 在真实商家后台重新创建订阅产品和计划,把
P-开头的 Live 计划 ID 填进config/pricing.ts的paypalPlanId.live。一次性积分包不需要这一步,它没有平台侧对象,换完凭证就直接生效。 - 确认商家账号已完成邮箱验证和收款资质,否则订单会卡在 Pending。
注意:
Live 的 Webhook URL 必须和你的网站实际访问地址一致(带不带
www是两个地址),否则收不到事件。
和 Stripe 的几点差异
集成前先知道这些差异,可以少踩坑:
- 没有托管的账单门户。Stripe 有 Billing Portal,PayPal 没有对应的东西,模板里点击「管理订阅」会跳转到用户自己的 PayPal 自动付款页面
https://www.paypal.com/myaccount/autopay/。取消订阅则是模板调 API 完成的。 - 一个结算主体同时只能有一个有效订阅。想换计划需要先取消再购买,否则下单会被拦截。
- 付款可能处于 Pending 状态。用 eCheck 之类的方式付款时,PayPal 会先返回 PENDING,钱还没到账。模板此时只落一条待处理订单、不发积分,等到
PAYMENT.CAPTURE.COMPLETED事件到达才真正发放。如果一直没等到,定时任务/api/cron/credits会做一次兜底核对,记得配置CRON_SECRET并挂上定时触发。 - 积分包不支持团队工作区购买,只能在个人工作区下单,这条规则和 Stripe 一致。
结语
到这里 PayPal 的集成就完成了。建议在 Sandbox 把「一次性付款 - 积分到账」「订阅 - 续费 - 取消」「退款 - 积分扣回」三条链路各跑一遍,再切 Live。