跳转到主要内容

复制 Skill

打开 Skill 原始文件,将完整内容复制到剪贴板。

下载 Skill (.md)

下载 Skill 文件,添加到你的 AI 编程助手或 IDE 中。

@waffo/pancake-ts 是 Waffo Pancake 的服务端 TypeScript SDK。它处理请求签名、收银台会话创建、Webhook 验证和 GraphQL 查询。

常见陷阱 — 先读这里

这些是导致集成失败的常见错误。写代码之前先过一遍。
陷阱为什么会出问题解决方法
以 JSON 方式读取 Webhook bodyrequest.json() 会重新序列化 body,改变空白字符。签名验证对比的是原始字节始终使用 request.text()(App Router / Hono)或 express.raw()(Express)。
使用 localtunnel 做 Webhook 隧道localtunnel 会丢弃自定义 HTTP header。X-Waffo-Signature 根本到不了你的处理程序。改用 cloudflared tunnel --url http://localhost:3000
忘记调用 .publish()产品默认创建在 test 环境。为未发布产品创建生产收银台会话会静默失败。上线前调用 client.onetimeProducts.publish({ id })client.subscriptionProducts.publish({ id })
访问 result 而非 result.data(GraphQL)GraphQL 客户端返回 { data: T | null, errors?: [...] }。字段嵌套在 .data 下。始终解构:const stores = result.data?.stores ?? []
传入美元金额而非美分所有金额使用最小货币单位$29.00 = 2900¥1000 = 1000(JPY 无小数)。再三确认:创建 $9.99 的产品时传入 999,不是 9.99
GraphQL 变量使用 $id: ID!后端使用 $id: String!,不是 $id: ID!。类型错误会静默返回 nullID 变量始终声明为 String!
产品组更新中的 productIds 是完整替换调用 subscriptionProductGroups.update({ productIds: [...] }) 会替换整个列表,而不是追加。始终传入完整的目标列表,而不是只传新增项。

使用场景

Waffo Pancake 是一个 merchant-of-record 支付平台。以下项目类型适合使用 SDK:
  • SaaS 订阅计费 — 月付/年付计划,支持升降级(例如:Free/Pro/Team 层级)
  • 数字商品销售 — 电子书、模板、课程、许可证的一次性购买
  • 按次付费 — 按下载、API 调用或生成报告等操作收费
  • 混合模式 — 订阅 + 一次性购买组合
  • 多币种定价 — 按币种设置价格,自动处理税务
项目类型付费模式需要创建的产品
AI Skills 市场按次下载 + Pro 订阅1 个一次性产品($0.99/次下载)+ 2 个订阅产品(月付/年付)
在线课程平台按课程一次性购买每门课程 1 个一次性产品(2929–199)
SaaS 工具(Starter/Pro/Enterprise)订阅层级3 个订阅产品 + 1 个产品组用于计划切换
设计模板商店按模板一次性购买每个模板 1 个一次性产品,或 1 个共享产品配合 priceSnapshot 覆盖价格
API 服务(用量额度)额度包 + 订阅每个额度包 1 个一次性产品 + 订阅提供月度配额

安装与配置

npm install @waffo/pancake-ts
仅限服务端使用。Node.js 18+。零依赖。
import { WaffoPancake } from "@waffo/pancake-ts";

const client = new WaffoPancake({
  merchantId: process.env.WAFFO_MERCHANT_ID!,
  privateKey: process.env.WAFFO_PRIVATE_KEY!,
});
需要两个环境变量 — 注册时提供:
WAFFO_MERCHANT_ID=<你的商户ID>
WAFFO_PRIVATE_KEY=<你的RSA私钥>

PEM 私钥处理

转义换行符(最简单):
WAFFO_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----"
Base64 编码(推荐用于 CI/CD):
cat private.pem | base64 | tr -d '\n'
const privateKey = Buffer.from(process.env.WAFFO_PRIVATE_KEY_BASE64!, "base64").toString("utf-8");
文件路径(本地开发):
import { readFileSync } from "fs";
const privateKey = readFileSync("./keys/private.pem", "utf-8");

快速开始:从零创建

// 1. Create a store
const { store } = await client.stores.create({ name: "My SaaS" });

// 2. Create products
const { product: monthly } = await client.subscriptionProducts.create({
  storeId: store.id,
  name: "Pro Monthly",
  billingPeriod: "monthly",
  prices: { USD: { amount: 999, taxIncluded: true, taxCategory: "saas" } },
});

const { product: yearly } = await client.subscriptionProducts.create({
  storeId: store.id,
  name: "Pro Yearly",
  billingPeriod: "yearly",
  prices: { USD: { amount: 9900, taxIncluded: true, taxCategory: "saas" } },
});

// 3. Create a checkout session
const session = await client.checkout.createSession({
  storeId: store.id,
  productId: monthly.id,
  productType: "subscription",
  currency: "USD",
});
// Redirect customer to session.checkoutUrl
将 Store ID 和 Product ID 保存到环境变量中供后续使用。

快速开始:使用已有产品

如果产品已在 Dashboard 中创建,复制 Product ID 直接创建收银台会话:
const session = await client.checkout.createSession({
  productId: process.env.WAFFO_PRO_MONTHLY_PRODUCT_ID!,
  productType: "subscription",
  currency: "USD",
  buyerEmail: "customer@example.com",
  successUrl: "https://myapp.com/welcome",
});
// Redirect customer to session.checkoutUrl

API 参考

商店

// Create
const { store } = await client.stores.create({ name: "My Store" });

// Update (partial — only provided fields change)
const { store } = await client.stores.update({
  id: "store_id",
  name: "New Name",
  supportEmail: "help@example.com",
  website: "https://example.com",
});

// Soft-delete
const { store } = await client.stores.delete({ id: "store_id" });

一次性产品

const { product } = await client.onetimeProducts.create({
  storeId: "store_id",
  name: "E-Book",
  description: "A great e-book",
  prices: {
    USD: { amount: 2900, taxIncluded: false, taxCategory: "digital_goods" },
    EUR: { amount: 2700, taxIncluded: true, taxCategory: "digital_goods" },
  },
  successUrl: "https://example.com/thanks",
  metadata: { sku: "EB-001" },
});

// Update (creates new immutable version)
const { product } = await client.onetimeProducts.update({
  id: "product_id",
  name: "E-Book v2",
  prices: { USD: { amount: 3900, taxIncluded: false, taxCategory: "digital_goods" } },
});

// Publish test → production (required before going live)
const { product } = await client.onetimeProducts.publish({ id: "product_id" });

// Activate / deactivate
const { product } = await client.onetimeProducts.updateStatus({
  id: "product_id",
  status: "inactive", // or "active"
});
taxCategory 选项: digital_goods | saas | software | ebook | online_course | consulting | professional_service

订阅产品

const { product } = await client.subscriptionProducts.create({
  storeId: "store_id",
  name: "Pro Monthly",
  billingPeriod: "monthly", // "weekly" | "monthly" | "quarterly" | "yearly"
  prices: { USD: { amount: 999, taxIncluded: true, taxCategory: "saas" } },
});

// update, publish, updateStatus — same pattern as one-time products

订阅产品组

产品组支持共享试用期和订阅计划之间的切换。
const { group } = await client.subscriptionProductGroups.create({
  storeId: "store_id",
  name: "Pro Plans",
  rules: { sharedTrial: true },
  productIds: ["monthly_product_id", "yearly_product_id"],
});

// Update (productIds is FULL REPLACEMENT, not append)
await client.subscriptionProductGroups.update({
  id: "group_id",
  productIds: ["monthly_id", "quarterly_id", "yearly_id"],
});

// Publish to production
await client.subscriptionProductGroups.publish({ id: "group_id" });

// Delete (physical delete, not soft-delete)
await client.subscriptionProductGroups.delete({ id: "group_id" });

收银台会话

const session = await client.checkout.createSession({
  storeId: "store_id",          // optional if productId is from an existing store
  productId: "product_id",
  productType: "onetime",       // "onetime" | "subscription"
  currency: "USD",
  buyerEmail: "buyer@example.com",           // optional, pre-fills email
  successUrl: "https://example.com/thanks",  // redirect after payment
  metadata: { orderId: "internal-123" },     // custom key-value pairs
  // Optional overrides:
  priceSnapshot: { amount: 1999, taxIncluded: true, taxCategory: "saas" },
  billingDetail: { country: "US", isBusiness: false },
  expiresInSeconds: 3600,  // default: 7 days
});

// session.checkoutUrl  — redirect customer here
// session.sessionId    — for tracking
// session.expiresAt    — ISO 8601 expiry

取消订阅

const { orderId, status } = await client.orders.cancelSubscription({
  orderId: "order_id",
});
// status: "canceled" (was pending) or "canceling" (active → ends at period end)

GraphQL 查询

只读查询。ID 变量使用 String!(不是 ID!)。
const result = await client.graphql.query<{
  stores: Array<{ id: string; name: string; status: string }>;
}>({
  query: `query { stores { id name status } }`,
});
const stores = result.data?.stores ?? [];

// With variables — note String!, not ID!
const result = await client.graphql.query<{
  onetimeProduct: { id: string; name: string; prices: unknown };
}>({
  query: `query ($id: String!) { onetimeProduct(id: $id) { id name prices } }`,
  variables: { id: "product_id" },
});
const product = result.data?.onetimeProduct;

Webhook 验证

SDK 内置 test 和 prod 两个环境的公钥。验证只需一次函数调用。

Next.js App Router

import { verifyWebhook } from "@waffo/pancake-ts";

export async function POST(request: Request) {
  const body = await request.text(); // MUST be raw text, not .json()
  const sig = request.headers.get("x-waffo-signature");
  try {
    const event = verifyWebhook(body, sig);
    // event.eventType, event.data, event.storeId, event.mode
    return new Response("OK");
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }
}

Express

import express from "express";
import { verifyWebhook } from "@waffo/pancake-ts";

// MUST use express.raw(), not express.json()
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verifyWebhook(
      req.body.toString("utf-8"),
      req.headers["x-waffo-signature"] as string,
    );
    res.status(200).send("OK");
  } catch {
    res.status(401).send("Invalid signature");
  }
});

Hono

import { verifyWebhook } from "@waffo/pancake-ts";

app.post("/webhooks", async (c) => {
  const body = await c.req.text(); // raw text
  const sig = c.req.header("x-waffo-signature");
  try {
    const event = verifyWebhook(body, sig);
    return c.text("OK");
  } catch {
    return c.text("Invalid signature", 401);
  }
});

验证选项

// Explicit environment (skip auto-detection)
const event = verifyWebhook(body, sig, { environment: "prod" });

// Custom replay tolerance (default: 5 minutes)
const event = verifyWebhook(body, sig, { toleranceMs: 600000 });

// Disable replay protection (not recommended in production)
const event = verifyWebhook(body, sig, { toleranceMs: 0 });

事件类型

事件触发条件
order.completed一次性支付成功
subscription.activated首次订阅支付成功
subscription.payment_succeeded续费支付成功
subscription.canceling发起取消(当前周期结束前仍有效)
subscription.uncanceled撤回取消
subscription.updated计划变更(升级/降级)
subscription.canceled订阅完全终止
subscription.past_due续费支付失败
refund.succeeded退款完成
refund.failed退款失败

事件结构

interface WebhookEvent {
  id: string;           // Delivery ID (use for idempotent dedup)
  timestamp: string;    // ISO 8601 UTC
  eventType: string;    // e.g. "order.completed"
  eventId: string;      // Business event ID (payment/order ID)
  storeId: string;
  mode: "test" | "prod";
  data: {
    orderId: string;
    buyerEmail: string;
    currency: string;
    amount: number;     // smallest currency unit
    taxAmount: number;
    productName: string;
  };
}

配置 Webhook URL

await client.stores.update({
  id: "store_id",
  webhookSettings: {
    testWebhookUrl: "https://your-domain.com/api/webhooks",
    prodWebhookUrl: "https://your-domain.com/api/webhooks",
    testEvents: [
      "order.completed",
      "subscription.activated",
      "subscription.canceled",
      "subscription.past_due",
    ],
    prodEvents: [
      "order.completed",
      "subscription.activated",
      "subscription.canceled",
      "subscription.past_due",
    ],
  },
});

错误处理

import { WaffoPancakeError } from "@waffo/pancake-ts";

try {
  await client.stores.create({ name: "My Store" });
} catch (err) {
  if (err instanceof WaffoPancakeError) {
    console.log(err.status);           // HTTP status code
    console.log(err.errors);           // Array of { message, layer }
    console.log(err.errors[0].layer);  // "store" | "product" | "order" | ...
  }
}
错误按调用栈深度排序:errors[0] 是根本原因(最深层),errors[n] 是最外层调用者。

开发提示

  1. Webhook 隧道 — 使用 cloudflared,不要用 localtunnel(见陷阱表)。
brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel --url http://localhost:3000
  1. 幂等性自动处理 — SDK 根据 merchantId + path + body 自动生成确定性键。重试是安全的。
  2. Test → Prod 工作流 — 产品默认创建在 test 环境。使用 .publish() 发布到生产环境。Webhook 事件包含 mode: "test" | "prod",你的处理程序可以据此区分。

快速上手清单

  1. npm install @waffo/pancake-ts
  2. 配置 WAFFO_MERCHANT_IDWAFFO_PRIVATE_KEY 环境变量
  3. 初始化 new WaffoPancake({ merchantId, privateKey })
  4. 创建或引用商店
  5. 创建或引用产品
  6. 创建收银台:client.checkout.createSession(...) → 重定向到 checkoutUrl
  7. 处理 Webhook:verifyWebhook(rawBody, sig)必须使用 request.text()
  8. 配置 Webhook URL:client.stores.update({ webhookSettings: ... })