Skip to main content

The Simple Version

Tell your AI assistant:
Read https://docs.waffo.ai/llms-full.txt, load the official Waffo Pancake skill from https://docs.waffo.ai/integrate/skill, and integrate Waffo Pancake payments into the current project.
That’s it. Use this page as the AI integration entry point, then open the official skill file below when you need the exact SKILL.md.

The Full Version

For a complete integration with end-to-end tests:
Read https://docs.waffo.ai/llms-full.txt, load the official Waffo Pancake skill from https://docs.waffo.ai/integrate/skill, and use Waffo Pancake SDK to
integrate Waffo Pancake payments into the current project and run through
the full checkout flow:
1. Get Merchant ID from Dashboard → Merchant → Integration (use this as `WAFFO_MERCHANT_ID`, not `storeId`)
2. Create an API Key from Dashboard → Merchant → Integration → API Keys
3. Use only `WAFFO_MERCHANT_ID` and `WAFFO_PRIVATE_KEY` as required env vars for the first working integration
4. Install @waffo/pancake-ts SDK
5. Create checkout and webhook endpoints
6. Test with card 4576750000000110
7. Verify webhook receives order.completed event
Use test environment.

Official Waffo Pancake Skill

Open the official skill file from the AI Integration page to view, copy, or download the exact SKILL.md used by the team.

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

AI Coding Workflow

AI coding agents are most useful when you already understand the business model but want help turning it into a clean Waffo catalog and implementation plan. Typical tasks:
  • Convert a pricing page into Waffo products and product groups
  • Decide which offers should be subscription products vs one-time charges
  • Design dynamic pricing flows with priceSnapshot
  • Batch-generate product definitions, metadata, and rollout checklists
  • Review an existing catalog for naming, plan structure, and production readiness
1

Describe the business model

Explain what you sell, how customers are charged, and which parts are fixed-price versus usage-based.
2

Ask for a catalog plan

Have the agent map your offers into one-time products, subscription products, product groups, and optional dynamic pricing flows.
3

Review the output

Confirm naming, billing periods, tax categories, and whether any add-ons should remain one-time charges.
4

Implement

Use the output to create products in the Dashboard or to generate SDK/API integration code.

Prompt Templates

1. Turn a Pricing Page into Waffo Products

Read https://docs.waffo.ai/llms-full.txt.

I need you to turn this pricing model into a Waffo Pancake catalog:
- Starter: $19/month
- Pro: $59/month
- Scale: custom annual contract
- Overage: $0.20 per extra credit
- Optional onboarding fee: $499 one time

Please output:
1. Which items should be subscription products
2. Which items should be one-time products
3. Which subscription products should be grouped together
4. Which flows require dynamic pricing via priceSnapshot
5. Recommended product names, tax categories, and environment rollout order

2. Plan Dynamic Pricing

Read https://docs.waffo.ai/llms-full.txt.

I already have a subscription business in Waffo Pancake. I need to add dynamic pricing for overage billing.

Please design:
1. The base one-time product I should create
2. When to use priceSnapshot
3. What data should be calculated on my server before checkout
4. How to explain this clearly to my team so they do not confuse it with subscription billing

3. Review an Existing Catalog

Read https://docs.waffo.ai/llms-full.txt.

Review this Waffo product catalog and tell me:
1. Which names are unclear
2. Which subscription products should be grouped
3. Where dynamic pricing should replace fixed pricing
4. Which products should stay one-time even though the business is subscription-led
5. What should be published to production first

Practical Rules

SituationRecommended model
Fixed public priceProduct price stored on the product
Runtime-calculated amountCheckout session with priceSnapshot
Recurring planSubscription product
Multiple subscription tiersOne subscription product per tier + product group
Setup fee or credits top-upOne-time product
Overage charge for a subscription customerOne-time product with dynamic pricing
It is normal for a subscription-led business to create both subscription products and one-time charges. The charging model should match the business event, not the company label.

What To Avoid

  • Do not paste private keys or production secrets into prompts
  • Do not let an AI agent publish products to production without review
  • Do not model every pricing variation as a separate product if the final amount is computed at runtime
  • Do not force overage billing into subscription products when the charge is event-based

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 ?? [].
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
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>
WAFFO_MERCHANT_ID means your Merchant ID, not storeId and not a store identifier from a URL. storeId is still part of the current API model for store, product, and checkout management flows, so do not confuse the two. For the first working integration, only these two env vars need to exist: WAFFO_MERCHANT_ID and WAFFO_PRIVATE_KEY. Store IDs and Product IDs are runtime values you can keep in code, app config, or your own database.

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: Path A

// 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: "9.99", taxIncluded: true, taxCategory: "saas" } },
});

const { product: yearly } = await client.subscriptionProducts.create({
  storeId: store.id,
  name: "Pro Yearly",
  billingPeriod: "yearly",
  prices: { USD: { amount: "99.00", 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 IDs and Product IDs are follow-up values. Save them wherever your app keeps runtime configuration; they do not need to be env vars unless you want that convention.

Quick Start: Path B

If products already exist in the Dashboard, copy the Product ID and go straight to checkout. In this flow, you still only need the same two env vars above:
const session = await client.checkout.createSession({
  productId: "PROD_xxx_from_dashboard",
  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: 29.00, taxIncluded: false, 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: 39.00, 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: 9.99, 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: 19.99, 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

Order-Level Parameters & Priority

Parameters passed to createSession can override product-level settings. Understanding the priority hierarchy is essential for AI integrations:
ParameterPurposePriority
priceSnapshotOverride product price (dynamic pricing)Highest — ignores the product’s set price
currencySpecify checkout currencyRequired — selects the matching currency from product prices
buyerEmailPre-fill consumer emailOptional — skips the email input step on the checkout page
billingDetailPre-fill billing info (country, tax ID, etc.)Optional — skips the address input on the checkout page
successUrlRedirect URL after successful paymentOverrides product-level successUrl
metadataCustom key-value pairs (internal order ID, etc.)Passed through to webhook event.data
withTrialEnable trial periodOverrides product-level trial settings
expiresInSecondsSession expiration timeDefault 45 minutes, max 7 days
darkModeCheckout page dark modetrue=dark / false=light / omit=store default
priceSnapshot is the key parameter for dynamic pricing. When priceSnapshot is provided, the price set on the product is completely ignored. Use cases include: usage-based tiered pricing, dynamic coupon discounts, A/B testing different price points, and more.
// Dynamic pricing example: override price based on usage tier
const session = await client.checkout.createSession({
  storeId: "store_id",
  productId: "api-credits-product-id",
  productType: "onetime",
  currency: "USD",
  priceSnapshot: { amount: 49.00, taxIncluded: true, taxCategory: "saas" }, // overrides product price
  buyerEmail: "user@example.com",
  metadata: { internalOrderId: "ORD-2024-001", tier: "growth" },
  successUrl: "https://myapp.com/purchase/success",
});

Webhook Integration Notes

Key points to keep in mind when integrating webhooks:
PointDescription
Raw bodyMust use request.text() to read the body, not .json() — signature verification relies on raw bytes
Idempotent handlingUse event.id (delivery ID) for deduplication — the same event may be retried multiple times
Return 200 immediatelyReturn 200 OK first, then process business logic asynchronously. Timeouts trigger retries
Environment distinctionevent.mode is "test" or "prod" — ensure test events don’t trigger production logic
Retry mechanismNon-2xx or timeout triggers retries (default 3 times, exponential backoff)
Typical webhook handler pattern:
import { verifyWebhook } from "@waffo/pancake-ts";

export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("x-waffo-signature");

  try {
    const event = verifyWebhook(body, sig);

    // Idempotency check
    if (await isDuplicate(event.id)) return new Response("OK");
    await markProcessed(event.id);

    // Dispatch by event type
    switch (event.eventType) {
      case "order.completed":
        await handleOrderCompleted(event.data);
        break;
      case "subscription.activated":
        await handleSubscriptionActivated(event.data);
        break;
      case "subscription.canceled":
        await handleSubscriptionCanceled(event.data);
        break;
      case "subscription.past_due":
        await handlePastDue(event.data);
        break;
    }

    return new Response("OK");
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }
}

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;     // USD dollar amount (e.g. 9.99)
    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: ... })