メインコンテンツへスキップ

Skill をコピー

Skill ファイルを開き、全内容をクリップボードにコピーします。

Skill をダウンロード (.md)

Skill ファイルをダウンロードして、AI コーディングエージェントや IDE に追加します。

@waffo/pancake-ts は Waffo Pancake 決済 API のサーバーサイド TypeScript クライアントです。リクエスト署名、チェックアウトセッション作成、Webhook 検証、GraphQL クエリを処理します。

よくある落とし穴 — 最初にお読みください

統合を壊すミスをまとめています。コードを書く前に必ず目を通してください。
落とし穴なぜ壊れるか修正方法
Webhook ボディを JSON として読み取るrequest.json() はボディを再シリアライズし、空白が変わります。署名検証は元の生バイト列と比較します。常に request.text()(App Router / Hono)または express.raw()(Express)を使用してください。
localtunnel を Webhook に使用するlocaltunnel はカスタム HTTP ヘッダーを削除します。X-Waffo-Signature がハンドラーに届きません。代わりに cloudflared tunnel --url http://localhost:3000 を使用してください。
.publish() を忘れる商品はデフォルトで test 環境に作成されます。未公開商品の本番チェックアウトセッションはサイレントに失敗します。本番稼働前に client.onetimeProducts.publish({ id }) または client.subscriptionProducts.publish({ id }) を呼び出してください。
GraphQL で result.data ではなく result にアクセスするGraphQL クライアントは { data: T | null, errors?: [...] } を返します。フィールドは .data の下にネストされています。常に分割代入してください:const stores = result.data?.stores ?? []
セントではなくドル金額を渡すすべての金額は最小通貨単位を使用します。$29.00 = 2900¥1000 = 1000(JPY は小数なし)。再確認:$9.99 の商品を作成する場合、9.99 ではなく 999 を渡してください。
GraphQL 変数で $id: ID! を使用するバックエンドは $id: String! を使用し、$id: ID! ではありません。間違った型を使うとサイレントに null が返されます。ID 変数は常に 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個、または priceSnapshot で価格を上書きする共有商品1個
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!,
});
必要な環境変数は2つだけです — サインアップ時に提供されます:
WAFFO_MERCHANT_ID=<your-merchant-id>
WAFFO_PRIVATE_KEY=<your-rsa-private-key>

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 を環境変数に保存して今後使用します。

クイックスタート:既存商品を使用

ダッシュボードですでに商品を作成済みの場合、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 には両環境の公開鍵が埋め込まれています。検証は関数呼び出し一回で完了します。

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() — express.json() ではなく express.raw() 必須
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 トンネリング — localtunnel ではなく cloudflared を使用してください(落とし穴の表を参照)。
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: ... })