Skip to main content

Copy Skill

Open the raw Skill file and copy the full content to your clipboard.

Download Skill (.md)

Download the Skill file to add to your AI coding agent or IDE.

The @waffo/pancake-ts SDK is a server-side TypeScript client for the Waffo Pancake payment API. It handles request signing, checkout session creation, webhook verification, and GraphQL queries.

Gotchas — Read This First

These are the mistakes that break integrations. Read before writing any code.
GotchaWhy It BreaksFix
Reading webhook body as JSONrequest.json() re-serializes the body, changing whitespace. Signature verification compares against the original raw bytes.Always use request.text() (App Router / Hono) or express.raw() (Express).
Using localtunnel for webhookslocaltunnel strips custom HTTP headers. X-Waffo-Signature never reaches your handler.Use cloudflared tunnel --url http://localhost:3000 instead.
Forgetting .publish()Products are created in test environment by default. Production checkout sessions for unpublished products will fail silently.Call client.onetimeProducts.publish({ id }) or client.subscriptionProducts.publish({ id }) before going live.
Accessing result instead of result.data in GraphQLThe GraphQL client returns { data: T | null, errors?: [...] }. Fields are nested under .data.Always destructure: const stores = result.data?.stores ?? [].
Passing dollar amounts instead of centsAll amounts use smallest currency unit. $29.00 = 2900, ¥1000 = 1000 (JPY has no decimal).Double-check: if creating a $9.99 product, pass 999 not 9.99.
Using $id: ID! in GraphQL variablesBackend uses $id: String!, not $id: ID!. Using the wrong type silently returns null.Always declare ID variables as String!.
productIds in group update is full replacementCalling subscriptionProductGroups.update({ productIds: [...] }) replaces the entire list — it does not append.Always pass the complete desired list, not just new additions.

Use Cases

Waffo Pancake is a merchant-of-record payment platform. The SDK fits projects that need:
  • SaaS subscription billing — monthly/yearly plans with upgrade/downgrade (e.g., Free/Pro/Team tiers)
  • Digital product sales — one-time purchases for e-books, templates, courses, licenses
  • Per-usage payments — charge per download, API call, or generated report
  • Hybrid models — subscriptions + one-time purchases combined
  • Multi-currency pricing — per-currency prices with automatic tax handling
Project TypePayment ModelProducts to Create
AI Skills marketplacePer-download + Pro subscription1 one-time ($0.99/download) + 2 subscriptions (monthly/yearly)
Online course platformOne-time per course1 one-time per course (2929–199)
SaaS (Starter/Pro/Enterprise)Subscription tiers3 subscriptions + 1 product group for plan switching
Template shopOne-time per template1 one-time per template, or 1 shared product with priceSnapshot override
API creditsCredit packs + subscription1 one-time per pack + subscription for monthly quota

Installation & Setup

npm install @waffo/pancake-ts
Server-side only. Node.js 18+. Zero dependencies.
import { WaffoPancake } from "@waffo/pancake-ts";

const client = new WaffoPancake({
  merchantId: process.env.WAFFO_MERCHANT_ID!,
  privateKey: process.env.WAFFO_PRIVATE_KEY!,
});
Two env vars are required — provided at signup:
WAFFO_MERCHANT_ID=<your-merchant-id>
WAFFO_PRIVATE_KEY=<your-rsa-private-key>

PEM Key Handling

Escaped newlines (simplest):
WAFFO_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----"
Base64 (recommended for CI/CD):
cat private.pem | base64 | tr -d '\n'
const privateKey = Buffer.from(process.env.WAFFO_PRIVATE_KEY_BASE64!, "base64").toString("utf-8");
File path (local dev):
import { readFileSync } from "fs";
const privateKey = readFileSync("./keys/private.pem", "utf-8");

Quick Start: Create from Scratch

// 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
Save the Store ID and Product IDs to env vars for future use.

Quick Start: Use Existing Products

If products already exist in the Dashboard, copy their IDs and go straight to checkout:
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 Reference

Stores

// 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" });

One-Time Products

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 options: digital_goods | saas | software | ebook | online_course | consulting | professional_service

Subscription Products

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

Subscription Product Groups

Groups enable shared trials and plan switching between subscription 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" });

Checkout Sessions

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

Cancel Subscription

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

GraphQL Queries

Read-only. Use String! for ID variables (not 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 Verification

The SDK embeds public keys for both environments. Verification is one function call.

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);
  }
});

Verification Options

// 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 });

Event Types

EventTrigger
order.completedOne-time payment succeeded
subscription.activatedFirst subscription payment succeeded
subscription.payment_succeededRenewal payment succeeded
subscription.cancelingCancel initiated (active until period end)
subscription.uncanceledCancellation withdrawn
subscription.updatedPlan changed (upgrade/downgrade)
subscription.canceledSubscription fully terminated
subscription.past_dueRenewal payment failed
refund.succeededRefund completed
refund.failedRefund failed

Event Shape

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;
  };
}

Configuring Webhook URLs

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",
    ],
  },
});

Error Handling

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 are ordered by call stack depth: errors[0] is the root cause (deepest layer), errors[n] is the outermost caller.

Development Tips

  1. Webhook tunneling — use cloudflared, not localtunnel (see Gotchas).
brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel --url http://localhost:3000
  1. Idempotency is automatic — the SDK generates deterministic keys from merchantId + path + body. Retries are safe.
  2. Test → Prod workflow — products default to test. Use .publish() to promote. Webhook events include mode: "test" | "prod" so your handler can distinguish.

Quick Start Checklist

  1. npm install @waffo/pancake-ts
  2. Set WAFFO_MERCHANT_ID and WAFFO_PRIVATE_KEY env vars
  3. Initialize new WaffoPancake({ merchantId, privateKey })
  4. Create or reference a store
  5. Create or reference product(s)
  6. Create checkout: client.checkout.createSession(...) → redirect to checkoutUrl
  7. Handle webhooks: verifyWebhook(rawBody, sig)must use request.text()
  8. Configure webhook URL: client.stores.update({ webhookSettings: ... })