关于 paddle-webhooks
paddle-webhooks 是面向开发者的技能,帮助在 Next.js 中构建 Paddle 服务端 Webhook 端点,讲解签名验证、事件处理、重试、幂等性和本地测试。
主要功能
- 01通知目标配置
- 02Next.js Route Handler 编写指南
- 03使用 Paddle SDK 验证签名
- 04重试与幂等处理
- 05事件顺序保护
- 06本地端点测试
适用场景
- 01接收订阅和交易更新
- 02拒绝签名无效的 Webhook
- 03对重试投递的事件去重
- 04用 Paddle 事件测试本地端点
MCP 相关
该技能介绍如何使用 Paddle MCP 工具管理通知目标、检查事件并重放失败的通知。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ paddlehq/ paddle-agent-skills --skill paddle-webhooks安装后在 Claude Code 中输入/paddle-webhooks确认已加载。
详细介绍
在 Next.js 中安全接收 Paddle Webhook
何时使用此技能
在构建用于接收 Paddle Webhook 事件(订阅变更、交易、客户更新、付款等)的服务端端点时,请使用此技能。此技能涵盖在 Paddle 中创建通知目标、编写用于验证签名并处理事件的 Next.js Route Handler、处理重试和幂等性,以及本地测试。
此技能是几乎所有服务端 Paddle 集成的基础。可搭配使用:
subscription-sync— 将 Webhook 事件转换为数据库中的记录。sandbox-testing— 使用 Webhook 模拟器向端点发送请求。checkout-web— 接收此处事件的客户端对应方案。
交付约定
此技能中的每个设计选择都基于 Paddle 交付 Webhook 的以下事实。请先阅读本节;其余内容都是具体操作方法。
- 只有在 5 秒内返回
2xx才算“已交付”。 任何其他响应——400、401、500、503、重定向、连接超时——都将被视为交付失败,并会触发重试。对于集成方,没有任何状态码能表示“停止重试”。Paddle 会重试任何非2xx响应。 - 重试计划。 Paddle 按指数退避计划重试。沙盒环境:总共尝试 3 次,均在约 2 分钟内完成。正式环境:最多总共尝试 60 次,历时约 2 天。连接超时与非
2xx响应共用同一重试次数预算。不要让处理程序依赖精确的重试时间。 - 每次重试的
event.eventId都相同。 Paddle 会重新发送完全相同的载荷(签名时间戳会刷新),直到收到2xx响应或重试次数耗尽。此 ID 就是你的去重键。 - 不保证事件顺序。 重试或重放事件可能会在更新的事件之后到达。存储
event.occurredAt,且仅应用比上次应用的事件更新的事件(步骤 5)。 - 不会跟随重定向。
301或302会被视为交付失败,不会继续跟随。 - 失败的通知可重放约 90 天。 重试次数耗尽后,通知会标记为
failed。可在 Paddle > Events > Notifications 中重放(打开目标,然后在通知上选择 Retry),也可在execute调用中通过 Paddle MCP 服务执行client.notifications.replay("ntf_…")(通知 ID 是路径中的位置参数,不是请求体字段)。重放会为同一事件 ID 创建新的通知 ID,并尝试发送一次。事件会保留 90 天:长时间停机后,可使用GET /events及其from/to筛选条件列出遗漏事件,或查询 API 获取当前状态。
每个 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。
前置条件
你需要:
- 一个已配置产品和价格的 Paddle 账户(开发时可使用 sandbox)——如果还没有,请参阅
catalog-setup。 - 一个可公开访问的开发环境 URL,以便真实的 Paddle 事件可以发送到本地代码(请参阅下文的“本地测试”)。
- 三个环境变量:
NEXT_PUBLIC_PADDLE_ENV=sandbox # or "production"
PADDLE_API_KEY=pdl_sdbx_apikey_... # server-side; never expose to the browser
PADDLE_NOTIFICATION_WEBHOOK_SECRET=pdl_ntfset_... # secret for THIS notification destination
验证 Webhook 不会调用 API,因此路由处理程序无需 API 密钥权限。通过 API 或 Paddle MCP 管理 Webhook 则需要相应权限:创建或更新目标需要 notification_setting.write(列出目标或读取密钥需要 notification_setting.read),通知、日志和事件需要 notification.read,重放需要 notification.write,运行模拟需要 notification_simulation.write(检查结果需要 notification_simulation.read)。请在 Paddle > My account > Settings > Authentication 下创建 API 密钥。
安装服务端 SDK:
npm install @paddle/paddle-node-sdk
步骤 1:创建通知目标
通知目标会告诉 Paddle“将这些事件发送到此 URL”。每个目标都有自己的密钥——沙盒和生产环境应使用不同的目标和不同的密钥,绝不能共用。
- 在 Paddle 控制面板中,前往 Paddle > Events > Notifications。
- 点击 New destination。
- 设置:
- Notification type:
Webhook。 - URL:你的端点(例如,
https://your-app.com/api/webhook或通过隧道映射的本地 URL)。 - Description:
Local dev(或Production)。 - Usage type:开发目标选择
Both,这样它会接收真实的沙盒事件和模拟器事件。生产环境适合选择Platform(仅真实事件)。 - Events:选择你需要的事件。建议从
transaction.completed、subscription.created、subscription.updated、subscription.canceled、customer.created、customer.updated开始。之后可以订阅更多事件。
- Notification type:
- 保存,然后将目标的 Secret key 复制到
PADDLE_NOTIFICATION_WEBHOOK_SECRET。之后可以在目标的编辑视图中再次复制,也可以从GET /notification-settings/{id}的endpoint_secret_key字段读取。
隧道 URL 发生变化时,请编辑现有目标的 URL,而不要创建新目标,这样密钥就会保持不变。
如果你可以使用 Paddle MCP 服务,请在 execute 调用中创建目标,而不要使用控制面板(资源使用 camelCase,正文使用 snake_case;参见上面的约定):
async (client) =>
client.notificationSettings.create({
description: "Local dev", // required
destination: "https://abc123.ngrok.app/api/webhook",
type: "url",
subscribed_events: ["transaction.completed", "subscription.created", "subscription.updated"],
traffic_source: "all", // also receive simulator events; defaults to "platform"
});
响应中包含 endpoint_secret_key。如果出现 409 notification_setting_cannot_be_duplicate,说明该 URL 已存在目标:在 (await client.notificationSettings.list())["notification-settings"] 中找到该目标并复用,或者使用 client.notificationSettings.update("ntfset_…", { ... }) 修改该目标,而不要创建另一个目标。如果出现 400 notification_maximum_active_settings_reached,说明该账户已达到活动目标数量上限:复用用户自己的某个目标,或者询问用户要停用哪个目标。
使用 Paddle CLI 时,将相同的正文发送到 paddle notification-settings create。将密钥直接追加到 .env.local,避免它显示在终端或聊天中:
paddle --environment sandbox notification-settings create \
--jq '"PADDLE_NOTIFICATION_WEBHOOK_SECRET=" + .data.endpoint_secret_key' \
--json '{
"description": "Local dev",
"destination": "https://abc123.ngrok.app/api/webhook",
"type": "url",
"subscribed_events": ["transaction.completed", "subscription.created", "subscription.updated"],
"traffic_source": "all"
}' >> .env.local
使用 paddle --environment sandbox notification-settings list --jq '.data[] | {id, destination}' 查找新目标的 ID,之后使用 paddle notification-settings update ntfset_… --json '{ ... }' 修改它。
步骤 2:创建 Paddle SDK 辅助工具
创建一个 Node SDK 实例,避免在每次请求时都初始化它。
// utils/paddle/get-paddle-instance.ts
import { Environment, LogLevel, Paddle, type PaddleOptions } from "@paddle/paddle-node-sdk";
export function getPaddleInstance() {
const options: PaddleOptions = {
environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as Environment) ?? Environment.sandbox,
logLevel: LogLevel.error,
};
if (!process.env.PADDLE_API_KEY) {
throw new Error("PADDLE_API_KEY is not set");
}
return new Paddle(process.env.PADDLE_API_KEY, options);
}
步骤 3:编写路由处理程序
两条规则。对可以低成本检查的输入进行预验证(返回 400)。将其他所有操作放在单个 try/catch 中,任何异常都返回非 2xx 响应(包括签名验证失败),以便 Paddle 重试。任何非 2xx 响应都可以(本文使用 500;401 同样有效);只有返回 2xx 才会导致事件丢失。
// app/api/webhook/route.ts
import { NextRequest } from "next/server";
import { getPaddleInstance } from "@/utils/paddle/get-paddle-instance";
import { processEvent } from "@/utils/paddle/process-webhook";
export async function POST(request: NextRequest) {
const signature = request.headers.get("paddle-signature") ?? "";
const rawBody = await request.text();
const secret = process.env.PADDLE_NOTIFICATION_WEBHOOK_SECRET ?? "";
// Pre-validation: a request with no signature header or empty body
// can't be verified or processed. 400 is fine here — Paddle will still
// retry, but that's okay; this is a "you sent us nothing" case.
if (!signature || !rawBody) {
return Response.json({ error: "Missing signature or body" }, { status: 400 });
}
try {
const paddle = getPaddleInstance();
// Throws on signature mismatch, expired timestamp, or malformed event.
const eventData = await paddle.webhooks.unmarshal(rawBody, secret, signature);
if (eventData) {
await processEvent(eventData);
}
// Acknowledge fast. Heavy work belongs in a queue (see Step 6).
return Response.json({ received: true });
} catch (e) {
console.error("Webhook error:", e);
// Any non-2xx tells Paddle to retry — 401 and 500 are equally fine here;
// only a 2xx would mark the event delivered and lose it. A thrown
// unmarshal could be a tampered request OR a rotated secret that hasn't
// been redeployed — they're indistinguishable, so retrying recovers the
// second case automatically and the first is harmless. We return 500.
return Response.json({ error: "Internal error" }, { status: 500 });
}
}
paddle.webhooks.unmarshal() 一次调用会完成三件事:
- 使用你的密钥,根据原始请求正文验证
paddle-signature标头中的 HMAC 签名。 - 如果签名无效、时间戳过旧或负载格式错误,则抛出异常。
- 返回一个包含反序列化负载的类型化
EventEntity。
为什么即使签名验证失败,也要使用一个 catch 并统一返回一个非 2xx 响应?unmarshal 抛出的异常不会告诉你_抛出原因_——请求被篡改、密钥错误或已轮换、时间戳过期以及事件格式错误,都会表现为同一个通用错误。所有非 2xx 响应都会按相同的重试次数重试,因此 401 和 500 对事件而言同样安全:无论选择哪一个,轮换密钥都会在重新部署后自动恢复。不要尝试区分它们(对“伪造”请求返回 401,对“暂时性”错误返回 500)——你无法区分这些情况,因此请选择一个非 2xx 响应,并在整个 catch 中使用它。只有在失败时返回 2xx 才会导致事件丢失。
第 4 步:将事件路由到处理器
保持路由处理器精简。将特定事件的逻辑移到单独的函数中,并使用 EventName 枚举根据 eventType 进行分支。TypeScript 会在每个分支中缩窄 event.data 的类型:
// utils/paddle/process-webhook.ts
import {
type EventEntity,
EventName,
type SubscriptionCreatedEvent,
type SubscriptionUpdatedEvent,
type SubscriptionCanceledEvent,
type TransactionCompletedEvent,
type CustomerCreatedEvent,
type CustomerUpdatedEvent,
} from "@paddle/paddle-node-sdk";
/**
* Paddle delivers at-least-once and in no guaranteed order. The same
* event.eventId arrives on every retry, so every handler below MUST be
* idempotent: UPSERT keyed on the Paddle resource id and guarded by
* event.occurredAt (Step 5), or dedupe on event.eventId before non-DB
* side effects.
*/
export async function processEvent(event: EventEntity) {
// Idempotency: handlers may run multiple times for the same event.eventId.
switch (event.eventType) {
case EventName.SubscriptionCreated:
case EventName.SubscriptionUpdated:
case EventName.SubscriptionCanceled:
return handleSubscription(event);
case EventName.TransactionCompleted:
return handleTransactionCompleted(event);
case EventName.CustomerCreated:
case EventName.CustomerUpdated:
return handleCustomer(event);
default:
// Subscribed to events you don't handle yet? No-op. Better than throwing.
return;
}
}
async function handleSubscription(
event: SubscriptionCreatedEvent | SubscriptionUpdatedEvent | SubscriptionCanceledEvent,
) {
// TODO: UPSERT keyed on event.data.id, applied only if event.occurredAt is
// newer than the stored value. See Step 5 and subscription-sync.
}
async function handleTransactionCompleted(event: TransactionCompletedEvent) {
// TODO: idempotent UPSERT keyed on event.data.id, OR dedupe on event.eventId
// before non-DB side effects (sending receipts, granting credits).
}
async function handleCustomer(event: CustomerCreatedEvent | CustomerUpdatedEvent) {
// TODO: idempotent UPSERT keyed on event.data.id.
}
Node SDK 导出了 EventName(字符串判别值)以及每种事件对应的类型化事件,因此当你根据 eventType 进行分支时,TypeScript 会缩窄 event.data 的类型。像 event.eventType === "subscription.created" 这样的字符串字面量比较也能缩窄类型;使用 EventName 可读性更好,也更便于安全重构。
第 5 步:让处理器具备幂等性并能安全处理乱序事件
默认做法:将处理器写成以 Paddle 资源 ID 为键的 UPSERT,并将事件的 occurredAt 与数据一同存储。Paddle 不保证按顺序投递,因此重试或重放的旧事件可能在新事件之后到达;只有当事件的 occurredAt 晚于已存储的值时,才应用该事件。将检查和写入放在同一条原子语句中,避免并发投递交错执行:
async function handleSubscription(
event: SubscriptionCreatedEvent | SubscriptionUpdatedEvent | SubscriptionCanceledEvent,
) {
// Insert, or update only if this event is newer than the last one applied.
await db.query(
`INSERT INTO subscriptions (id, status, next_billed_at, last_event_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
next_billed_at = EXCLUDED.next_billed_at,
last_event_at = EXCLUDED.last_event_at
WHERE subscriptions.last_event_at IS NULL
OR subscriptions.last_event_at < EXCLUDED.last_event_at`,
[event.data.id, event.data.status, event.data.nextBilledAt, event.occurredAt],
);
}
last_event_at 是一个 TIMESTAMPTZ 列;event.occurredAt 是 ISO 8601 字符串。比较事件实际发生的时间,而不是收到事件的时间。这样,重复和迟到的投递不会产生任何变化,也无需额外记录。大多数 Webhook 处理都适用于这种模式——订阅状态、客户记录、交易记录都有稳定的 Paddle ID,可用作键。Supabase 版本请参见 subscription-sync。
UPSERT 不够时:事件 ID 账本
有些副作用本身并不具备幂等性,例如发送收据邮件、发放一次性额度,或调用按请求计费的第三方 API。对于这些操作,请使用 event.eventId 显式去重:
import { db } from "@/lib/db";
export async function processEvent(event: EventEntity) {
const seen = await db.processedWebhooks.findUnique({
where: { eventId: event.eventId },
});
if (seen) return; // Already handled — return 200 from the route.
await db.$transaction(async (tx) => {
await routeEvent(event, tx);
await tx.processedWebhooks.create({ data: { eventId: event.eventId } });
});
}
最简表结构:
CREATE TABLE processed_webhooks (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
);
仅当 UPSERT 模式无法覆盖该副作用时,才使用账本。对于大多数订阅、客户和交易同步工作,带条件检查的 UPSERT 就足够了。
第 6 步:快速确认——将繁重工作放入队列
5 秒超时是确实存在的。如果处理器运行时间更长,Paddle 会将其视为连接超时,将此次投递标记为超时,并从你的重试额度中扣除此次尝试。
模式:
- 验证签名。
- 记录
event.eventId(如果操作开销很小,也可以同步对资源执行 upsert)。 - 将任何更繁重的工作——发送邮件、调用第三方 API、从
paddle.transactions.list()获取数据、生成 PDF——放入后台队列。 - 返回
200。
具体来说:不要在 Webhook 处理器中内联运行 paddle.transactions.list()。不要内联发送邮件。不要在路由处理器中调用 Stripe 或任何其他第三方服务。验证、入队、确认。
如果你还没有队列,使用 Vercel Queue、AWS SQS,或者简单地“插入 pending_jobs 表,再通过 cron 处理”就足够了。
本地测试
两种模式:
A. 将公共 URL 隧道转发到 localhost。 使用 ngrok、cloudflared 或 vercel dev --listen 0.0.0.0,然后建立隧道。
ngrok http 3000
# Use the https URL as your notification destination URL: https://abc123.ngrok.app/api/webhook
之后,沙盒中的真实 Paddle 事件会发送到你的本地路由处理程序。更改处理程序后,如需重新发送事件,可使用 paddle --environment sandbox notifications list --notification-setting-id ntfset_… 找到该事件,然后运行 paddle --environment sandbox notifications replay ntf_…。
B. 使用 webhook 模拟器。 模拟器会向通知目标发送事件,因此 localhost 仍需要隧道:
- 建立到 localhost 的隧道,并将目标指向该地址。其 Usage type 必须设为
Simulation或Both,且必须订阅你要模拟的事件;否则,模拟事件会标记为aborted,不会发送。 - 在控制面板中,前往 Paddle > Events > Simulations。
- 选择一个单独事件(名称类似事件类型,例如
subscription.created、transaction.paid),或选择一个场景:subscription_creation、subscription_renewal、subscription_pause、subscription_resume、subscription_cancellation。 - Paddle 会构造一个负载,使用你目标的密钥对其签名,然后向你的 URL 发送一次 POST 请求。模拟事件不会重试,也不会显示在通知日志中;请在模拟运行的事件中查看,你可以在那里重新播放事件。
完整的沙盒与模拟器工作流请参阅 sandbox-testing。
常见陷阱
- 验证失败时返回
2xx。 这是唯一会导致事件丢失的状态码错误:Paddle 只将2xx响应视为已送达,因此在unmarshal失败时返回2xx会将事件标记为已送达,且不会再重试。所有非2xx响应(400、401、500、503)都会按相同的重试策略重试,因此签名验证失败时返回401不会导致事件丢失(即使密钥已轮换,仍有完整的重试窗口来恢复)。从集成方来说,唯一会停止重试的状态码是2xx。如果确实需要应对滥用行为,请在边缘层通过速率限制处理,而不是在处理器内部处理。 - 将捕获的错误拆分为“签名验证失败 → 401”和“处理器错误 → 500”。 对于被篡改的请求、错误或已轮换的密钥、过期时间戳和格式错误的事件,
unmarshal都会以无法区分的方式抛出错误,因此这种拆分只是虚设——你实际上无法判断属于哪种情况。选择一个非2xx状态码,并用于整个catch。一个catch、一个状态码、一套运维方案。 - 验证前解析请求体。 如果先用
request.json()读取 JSON,再重新序列化以进行验证,字节序列就会与 Paddle 签名的内容不一致。始终使用await request.text(),并将原始字符串传给unmarshal()。 - 密钥错误。 每个通知目标都有自己的密钥。将沙盒密钥用于生产目标(或反过来)会导致
unmarshal在每次投递时都抛出错误。PADDLE_NOTIFICATION_WEBHOOK_SECRET必须与目标匹配,且与PADDLE_API_KEY的值_不同_。 - 处理器运行缓慢。 路由处理器中的工作若耗时 30 秒,会在 5 秒时超时,被计为投递失败,并消耗一次重试机会。验证、入队、确认。
- 把 Webhook 当作有序事件处理。 它们并非如此。如果首次投递正在重试,
subscription.updated可能会先于相应的subscription.created到达;较旧事件的重试或重放也可能在较新事件之后到达。普通的 UPSERT 会让较旧事件覆盖较新的状态。保存event.occurredAt,并且只应用较新的事件(第 5 步);对副作用按event.eventId去重。 - 长时间停机后遗漏事件。 实时投递尝试会在约两天后停止(沙盒环境:约两分钟)。此后,可从 Paddle > Events > Notifications 或通过
client.notifications.replay重放失败的通知(最长约 90 天),使用GET /events(from/to)列出遗漏事件,或查询 API 获取当前状态。 - 时钟偏差。 如果签名的
ts早于当前时间超过 5 秒,SDK 就会拒绝该签名,因此服务器时钟超前会导致每次unmarshal都抛出错误。保持服务器时钟同步(NTP)。 - IP 允许列表。 如果防火墙只允许已知来源访问,请将
GET /ips返回的地址加入允许列表。沙盒环境和生产环境返回的地址列表不同,因此请分别从各自的 API 基础 URL 获取。仍需验证签名。 - 忘记订阅正确的事件。 目标只会发送你在控制面板中勾选的事件。在代码中添加新处理器,却没有勾选对应事件,会导致它始终不会触发,而且不会有任何提示。
- 在 Pages Router 中解析请求体。 Pages Router 示例会禁用
bodyParser,以便访问原始请求体。App Router Route Handlers 无需这样做——request.text()始终会返回原始字节。
验证集成
- 在
processEvent顶部添加console.log(event.eventType, event.eventId)。 - 隧道映射本地主机(
ngrok http 3000),并将目标 URL 更新为隧道地址。目标的 Usage type 必须设为Simulation或Both。 - 在控制面板中,前往 Paddle > Events > Simulations,针对你的目标运行一次
subscription.created模拟。 - 确认:
- 路由处理程序记录了事件类型。
- 模拟运行中的事件显示 200 响应。模拟事件只尝试一次,不会出现在通知日志中。(如果有可用的 Paddle MCP 服务,
client.simulations.runsEvents.list("ntfsim_…", "ntfsimrun_…")会返回运行中的events,每个事件都包含其状态、请求和响应。)
- 故意篡改
.env.local中的密钥,并重放模拟事件。确认:- 处理程序返回非 2xx 响应(此技能的处理程序返回 500)。
- 运行中的事件显示投递失败。模拟事件不会重试。
- 恢复正确的密钥,然后再次重放事件(在控制面板中操作,或使用
client.simulations.runsEvents.replay(simulationId, runId, eventId))。这次应该成功。
- 触发一次真实流程:完成一次沙盒结账(参见
checkout-web),并确认收到由此产生的transaction.completed和subscription.created事件。真实投递记录会显示在 Paddle > Events > Notifications 下:打开你的目标,然后在某条通知上点击 View Logs。(使用 Paddle MCP 时,可通过client.notifications.list({ notification_setting_id: ["ntfset_…"] })查找通知 ID,然后调用client.notifications.logs.list("ntf_…", { per_page: 50 }),该方法会返回{ pagination, logs }。日志嵌套在notifications下,通知 ID 是一个位置路径参数。)
相关文档
- Webhook 概览
- 签名验证
- 通知目标
- 响应 Webhook(重试语义)
- 使用模拟器测试 Webhook
- 订阅事件参考
- Node SDK 参考
- 参考实现:paddle-nextjs-starter-kit — 请参阅
src/app/api/webhook/route.ts和src/utils/paddle/process-webhook.ts。