mcpField
P
paddle-webhooks

paddle-webhooks

github.com/paddlehq开发9114 次安装4 天前更新
代码仓库

关于 paddle-webhooks

paddle-webhooks 是面向开发者的技能,帮助在 Next.js 中构建 Paddle 服务端 Webhook 端点,讲解签名验证、事件处理、重试、幂等性和本地测试。

主要功能

  1. 01通知目标配置
  2. 02Next.js Route Handler 编写指南
  3. 03使用 Paddle SDK 验证签名
  4. 04重试与幂等处理
  5. 05事件顺序保护
  6. 06本地端点测试

适用场景

  1. 01接收订阅和交易更新
  2. 02拒绝签名无效的 Webhook
  3. 03对重试投递的事件去重
  4. 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”。每个目标都有自己的密钥——沙盒和生产环境应使用不同的目标和不同的密钥,绝不能共用。

  1. 在 Paddle 控制面板中,前往 Paddle > Events > Notifications。
  2. 点击 New destination。
  3. 设置:
    • 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 开始。之后可以订阅更多事件。
  4. 保存,然后将目标的 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() 一次调用会完成三件事:

  1. 使用你的密钥,根据原始请求正文验证 paddle-signature 标头中的 HMAC 签名。
  2. 如果签名无效、时间戳过旧或负载格式错误,则抛出异常。
  3. 返回一个包含反序列化负载的类型化 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 会将其视为连接超时,将此次投递标记为超时,并从你的重试额度中扣除此次尝试。

模式:

  1. 验证签名。
  2. 记录 event.eventId(如果操作开销很小,也可以同步对资源执行 upsert)。
  3. 将任何更繁重的工作——发送邮件、调用第三方 API、从 paddle.transactions.list() 获取数据、生成 PDF——放入后台队列。
  4. 返回 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 仍需要隧道:

  1. 建立到 localhost 的隧道,并将目标指向该地址。其 Usage type 必须设为 Simulation 或 Both,且必须订阅你要模拟的事件;否则,模拟事件会标记为 aborted,不会发送。
  2. 在控制面板中,前往 Paddle > Events > Simulations。
  3. 选择一个单独事件(名称类似事件类型,例如 subscription.created、transaction.paid),或选择一个场景:subscription_creation、subscription_renewal、subscription_pause、subscription_resume、subscription_cancellation。
  4. 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() 始终会返回原始字节。

验证集成

  1. 在 processEvent 顶部添加 console.log(event.eventType, event.eventId)。
  2. 隧道映射本地主机(ngrok http 3000),并将目标 URL 更新为隧道地址。目标的 Usage type 必须设为 Simulation 或 Both。
  3. 在控制面板中,前往 Paddle > Events > Simulations,针对你的目标运行一次 subscription.created 模拟。
  4. 确认:
    • 路由处理程序记录了事件类型。
    • 模拟运行中的事件显示 200 响应。模拟事件只尝试一次,不会出现在通知日志中。(如果有可用的 Paddle MCP 服务,client.simulations.runsEvents.list("ntfsim_…", "ntfsimrun_…") 会返回运行中的 events,每个事件都包含其状态、请求和响应。)
  5. 故意篡改 .env.local 中的密钥,并重放模拟事件。确认:
    • 处理程序返回非 2xx 响应(此技能的处理程序返回 500)。
    • 运行中的事件显示投递失败。模拟事件不会重试。
    • 恢复正确的密钥,然后再次重放事件(在控制面板中操作,或使用 client.simulations.runsEvents.replay(simulationId, runId, eventId))。这次应该成功。
  6. 触发一次真实流程:完成一次沙盒结账(参见 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 是一个位置路径参数。)

相关文档

评论

支持 Markdown