Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.waffo.ai/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Webhooks deliver real-time notifications to your server when events occur — orders, payments, subscriptions, and refunds.
Event occurs → Waffo Pancake → fan-out → Your channel(s)
A single store can have multiple webhooks, each delivering to a different channel. The available channels are:
ChannelDelivery targetPayload format
httpYour HTTPS endpointRSA-SHA256-signed JSON envelope
feishuLark / Feishu bot incoming webhookInteractive card (Lark format)
discordDiscord channel webhookEmbed message (Discord format)
telegramTelegram bot sendMessage URLHTML-formatted text
slackSlack incoming webhookAttachment with mrkdwn fields
The http channel uses the JSON envelope and signature verification described on this page — most of this guide covers that channel. For the IM channels, payloads use each platform’s native format and authentication is handled by the URL token; you don’t need to verify signatures.
Using TypeScript? The @waffo/pancake-ts SDK has built-in public keys and auto-detects the environment — one line to verify the http channel.

Setup

1

Choose a channel and prepare its URL

  • HTTP — build a server endpoint that accepts POST requests and returns 200.
  • Feishu / Discord / Telegram / Slack — create a bot or incoming webhook in the target platform and copy its URL. For Telegram, also note the chat ID that should receive messages.
2

(HTTP only) Get the verification public key

Go to Dashboard → Settings → Webhooks and copy the Webhook Public Key for the target environment (Test / Production).
3

Register the webhook

Add a webhook in Dashboard → Settings → Webhooks, or call POST /v1/actions/store/add-webhook. Each webhook record specifies one channel, one URL, the subscribed events, and the target environment (testMode: true for Test, false for Production). You can register multiple webhooks per store.
4

Send a test event

Use the Dashboard “Send Test Event” button to deliver a sample event to one or all of your registered webhooks.
5

Verify and handle events

For the HTTP channel, use the code examples below to verify signatures before processing events. IM channels deliver pre-rendered messages and require no handling on your side.

Environment Isolation

Each webhook is registered for a single environment via the testMode flag. Test and Production are fully independent:
AspectTestProduction
Webhook recordtestMode: truetestMode: false
Signing key (HTTP only)Test key pairProduction key pair
Verification public key (HTTP only)Dashboard Test keyDashboard Production key
Selector when deliveringHeader X-Environment: testHeader X-Environment: prod (or omitted)
The mode field in each HTTP payload indicates the source environment: "test" or "prod".
Always use the public key matching the event’s mode. A Test key cannot verify Production events, and vice versa.

Payload Format

Headers

HeaderDescription
Content-Typeapplication/json
X-Waffo-SignatureSignature string: t=<timestamp>,v1=<signature>
X-Waffo-EventEvent type (e.g., order.completed)

Body

{
  "id": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "timestamp": "2026-03-10T08:30:00.000Z",
  "eventType": "order.completed",
  "eventId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "storeId": "STO_3bVzrkD0FJjFdZNLk8Ualx",
  "storeName": "My Store",
  "mode": "prod",
  "data": {
    "orderId": "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
    "orderStatus": "completed",
    "buyerEmail": "buyer@example.com",
    "currency": "USD",
    "amount": "29.00",
    "taxAmount": "2.90",
    "taxRate": 0.1,
    "taxName": "Consumption Tax",
    "subtotal": "26.10",
    "total": "29.00",
    "productName": "Pro Plan",
    "orderMetadata": { "planId": "pro" },
    "productMetadata": {},
    "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
    "paymentStatus": "succeeded",
    "paymentMethod": "card",
    "paymentLast4": "4242",
    "paymentDate": "2026-03-10"
  }
}

Top-level Fields

FieldTypeDescription
idstringEvent entity ID — same as eventId for most events
timestampstringEvent time (ISO 8601 UTC)
eventTypestringEvent type (see Event Types)
eventIdstringBusiness event identifier — maps to different entities per event type (see eventId Mapping)
storeIdstringStore ID
storeNamestringStore name
modestring"test" or "prod"

data Fields

The data object contains transaction details. Some fields are always present; others appear only for specific event types or when the data is available. Always present:
FieldTypeDescription
orderIdstringOrder ID
orderStatusstringOrder status (e.g., "completed", "active", "canceling")
buyerEmailstringBuyer email address
currencystringISO 4217 currency code (e.g., "USD", "JPY")
amountstringTransaction amount including tax (display format, e.g., "29.00")
taxAmountstringTax amount (display format, e.g., "2.90")
productNamestringProduct name
orderMetadataobjectOrder-level metadata from checkout session (merchant-defined key-value pairs)
productMetadataobjectProduct-level metadata set when creating/updating the product
Included when available:
FieldTypePresent whenDescription
merchantProvidedBuyerIdentitystringSet at checkoutMerchant’s custom buyer identifier
billingDetailobjectSet at checkoutBilling address (country, isBusiness, etc.)
taxRatenumberTax appliedTax rate as decimal (e.g., 0.1 for 10%)
taxNamestringTax appliedTax name (e.g., "Consumption Tax")
subtotalstringAvailableSubtotal before tax (display format)
totalstringAvailableTotal after tax (display format)
productDescriptionstringSet on productProduct description
Payment events (order.completed, subscription.payment_succeeded):
FieldTypeDescription
paymentIdstringPayment ID
paymentStatusstringPayment status ("succeeded", "failed")
paymentMethodstringPayment method type (e.g., "card")
paymentLast4stringLast 4 digits of payment instrument
paymentDatestringPayment date (ISO 8601 date, e.g., "2026-03-10")
paymentFailureReasonstringFailure reason (when payment failed)
Subscription events (subscription.*):
FieldTypeDescription
billingPeriodstring"weekly", "monthly", "quarterly", "yearly"
currentPeriodStartstringCurrent billing period start (ISO 8601 date)
currentPeriodEndstringCurrent billing period end (ISO 8601 date)
canceledAtstringCancellation timestamp (ISO 8601, present when canceling/canceled)
Refund events (refund.succeeded, refund.failed):
FieldTypeDescription
refundStatusstring"succeeded" or "failed"
refundReasonstringRefund reason
refundCreatedAtstringRefund creation timestamp (ISO 8601)
paymentIdstringOriginal payment ID
paymentStatusstringOriginal payment status
paymentMethodstringOriginal payment method
paymentLast4stringOriginal payment last 4 digits
paymentDatestringOriginal payment date
Amounts are display format strings, already converted from minor units. For example, USD "29.00" = 2900 cents; JPY "4500" = ¥4500. Use subtotal and total for itemized display when available.

eventId Mapping

The eventId identifies the business entity that triggered the event:
Event TypeeventId maps toExample
order.completedPayment IDPAY_6eYCunG3IMmIgcQOnaXdoA
subscription.activatedOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.payment_succeededPayment IDPAY_6eYCunG3IMmIgcQOnaXdoA
subscription.cancelingOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.uncanceledOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.updatedOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.canceledOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.past_dueOrder ID + monthORD_5dXBtmF2HLlHfbPNm0Wcnz-2026-04
refund.succeededRefund IDREF_4cWAtlE1GKkGebONl9Xbnx
refund.failedRefund IDREF_4cWAtlE1GKkGebONl9Xbnx
subscription.past_due appends -YYYY-MM to the eventId. The same subscription triggers at most one past_due event per calendar month. If still overdue the next month, a new event fires.

Event Types

Overview

EventTriggereventId
order.completedOne-time order payment succeededPayment ID
subscription.activatedSubscription first payment succeededOrder ID
subscription.payment_succeededRenewal payment succeeded (not first)Payment ID
subscription.cancelingCancellation requested — active until period endsOrder ID
subscription.uncanceledCancellation withdrawnOrder ID
subscription.updatedProduct changed (upgrade/downgrade)Order ID
subscription.canceledSubscription terminated (period ended)Order ID
subscription.past_dueRenewal payment failedOrder ID + month
refund.succeededRefund completedRefund ID
refund.failedRefund failedRefund ID
subscription.uncanceled and subscription.updated event templates are ready and will activate once the corresponding features launch.

Event Details

Trigger: One-time order payment succeeds for the first time.Payload:
  • data.amount — Payment amount (including tax)
  • data.orderId — The one-time order ID
Recommended actions:
  • Deliver digital goods (license keys, download links, activation codes)
  • Update your order management system
  • Send buyer confirmation (if not using Waffo’s built-in emails)
The same order only triggers order.completed once. Refunds are notified via refund.succeeded / refund.failed.
Trigger: First payment on a new subscription succeeds (pendingactive).Payload:
  • data.amount — First payment amount (including tax)
  • data.productName — Subscription product name
Recommended actions:
  • Provision the subscriber’s account and grant access
  • Record the subscription start date
Only fires when a subscription transitions from pending to active for the first time. Subsequent renewals use subscription.payment_succeeded.
Trigger: A recurring renewal payment succeeds (not the first payment).Payload:
  • data.amount — This period’s renewal amount (including tax)
  • data.orderId — The subscription order ID
Recommended actions:
  • Extend the service period
  • Generate an invoice for this billing cycle
  • If the subscription was previously past_due, restore full access
Trigger: Buyer or merchant requests cancellation. The subscription remains active until the current paid period ends.Recommended actions:
  • Show “Subscription expires on [date]” notice
  • Offer a retention flow (e.g., discounted renewal)
  • Do not revoke access — the buyer has paid for the current period
The buyer can withdraw the cancellation before the period ends (triggers subscription.uncanceled).
Trigger: Cancellation is withdrawn before the current period ends.Recommended actions:
  • Remove the “expiring soon” notice
  • Restore auto-renewal status
Trigger: Subscription product changes (upgrade or downgrade).Payload:
  • data.productName — New product name after the change
  • data.amount — New amount
Recommended actions:
  • Update the buyer’s access level (add/remove features)
  • Update billing records
Trigger: Subscription is terminated — the paid period has ended and no further renewals will occur.Recommended actions:
  • Revoke access (or downgrade to a free tier)
  • Retain data for a grace period (in case the buyer re-subscribes)
  • Send a “subscription ended” confirmation
This is a terminal state. The subscription is irreversibly ended.
Trigger: Renewal payment fails and the subscription enters an overdue state.Payload:
  • data.amount — The amount due for this period
  • eventId — Format: {orderId}-YYYY-MM (monthly dedup)
Recommended actions:
  • Notify the buyer to update their payment method
  • Optionally degrade the service (limit features rather than fully revoking)
  • Do not revoke access immediately — the PSP may retry the charge automatically
Deduplication: At most one past_due event per subscription per calendar month. If still overdue next month, a new event fires.
Trigger: Refund has been completed and funds returned.Payload:
  • data.amount — Refund amount (including tax)
  • data.orderId — Original order ID
Recommended actions:
  • Revoke delivered digital goods (revoke licenses, disable downloads)
  • Update order status to “refunded”
Trigger: Refund processing failed.Recommended actions:
  • Log the failure for manual review
  • Do not revoke goods (the refund was not completed)

Subscription Lifecycle

Terminal StateMeaningFires Webhook?
canceledSubscription terminated (buyer/merchant cancel or overdue)subscription.canceled
closedNever activated — payment timed outNo
expiredFixed-term subscription naturally endedNo

Signature Verification

Always verify signatures in production. Without verification, anyone can send forged requests to your endpoint.

Algorithm

1. Parse t (timestamp in ms) and v1 (Base64 signature) from X-Waffo-Signature header
2. Build signature input: `${t}.${rawRequestBody}`
3. Verify v1 using RSA-SHA256 with the Waffo public key
4. (Recommended) Check that t is within 5 minutes of current time to prevent replay attacks
The SDK embeds public keys, auto-detects the environment, and handles format normalization:
import { verifyWebhook, WebhookEventType } from "@waffo/pancake-ts";

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

    switch (event.eventType) {
      case WebhookEventType.OrderCompleted:
        // Deliver digital goods
        break;
      case WebhookEventType.SubscriptionActivated:
        // Provision subscription access
        break;
      case WebhookEventType.SubscriptionCanceled:
        // Revoke access
        break;
    }
  } catch {
    res.status(401).send("Invalid signature");
  }
});
See the full SDK Webhook documentation.

Manual Verification

If you’re not using the TypeScript SDK, implement signature verification manually.
const crypto = require('crypto');

// From Dashboard → Developers → Webhook Public Key (PEM format)
const WAFFO_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
-----END PUBLIC KEY-----`;

function parseSignatureHeader(header) {
  const parts = {};
  for (const pair of header.split(',')) {
    const [key, ...rest] = pair.split('=');
    parts[key.trim()] = rest.join('=').trim();
  }
  return parts;
}

function verifyWebhookSignature(rawBody, signatureHeader, publicKey) {
  const { t, v1 } = parseSignatureHeader(signatureHeader);
  if (!t || !v1) return false;

  // Replay protection: 5-minute tolerance
  const tolerance = 5 * 60 * 1000;
  if (Math.abs(Date.now() - Number(t)) > tolerance) return false;

  // Verify RSA-SHA256 signature
  const signatureInput = `${t}.${rawBody}`;
  const verifier = crypto.createVerify('RSA-SHA256');
  verifier.update(signatureInput);
  return verifier.verify(publicKey, v1, 'base64');
}

app.post('/webhooks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-waffo-signature'];
    const rawBody = req.body.toString('utf-8');

    if (!sig || !verifyWebhookSignature(rawBody, sig, WAFFO_WEBHOOK_PUBLIC_KEY)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(rawBody);
    res.status(200).send('OK');

    // Process asynchronously
    handleEvent(event).catch(console.error);
  }
);

async function handleEvent(event) {
  switch (event.eventType) {
    case 'order.completed':
      await grantAccess(event.data.buyerEmail, event.data.productName);
      break;
    case 'subscription.activated':
      await createSubscription(event.data.buyerEmail, event.data.orderId);
      break;
    case 'subscription.payment_succeeded':
      await extendSubscription(event.data.orderId);
      break;
    case 'subscription.canceling':
      await markCanceling(event.data.orderId);
      break;
    case 'subscription.canceled':
      await revokeAccess(event.data.orderId);
      break;
    case 'subscription.past_due':
      await notifyPastDue(event.data.buyerEmail, event.data.orderId);
      break;
    case 'refund.succeeded':
      await revokeAccess(event.data.orderId);
      break;
    case 'refund.failed':
      await flagForReview(event.data.orderId);
      break;
  }
}
You must use the raw request body for signature verification. If your framework parses JSON automatically, the signature check will fail. Ensure you capture the unmodified raw string before verification.

Response Requirements

  • Return 2xx status code (recommended: 200)
  • Respond within 10 seconds
  • Response body does not matter
// Recommended: respond immediately, process async
app.post('/webhooks', (req, res) => {
  res.status(200).send('OK');
  processEventAsync(req.body);
});
Non-2xx responses or timeouts trigger retries.

Retry Policy

Failed deliveries are retried automatically with exponential backoff:
ItemDetails
RetriesUp to 3 (4 total attempts including the first)
StrategyExponential backoff
TimeoutNon-2xx or no response within the timeout window
Final failureDelivery marked as failed after all retries exhausted

Delivery Status

StatusDescription
pendingCreated, awaiting delivery or retrying
successDelivered successfully (your server returned 2xx)
failedAll retries exhausted
View delivery history in the Dashboard webhook logs, including status, HTTP response code, and response body (truncated to 1000 characters).

Handling Duplicates

Network issues may cause the same event to be delivered multiple times. Ensure your event handling is idempotent. Use the eventType + eventId combination (which has a unique constraint in the system) for deduplication:
async function handleEvent(event) {
  const exists = await db.query(
    'SELECT 1 FROM processed_webhooks WHERE event_type = $1 AND event_id = $2',
    [event.eventType, event.eventId]
  );
  if (exists.rows.length > 0) return; // Already processed

  await processEventLogic(event);

  await db.query(
    'INSERT INTO processed_webhooks (event_type, event_id, processed_at) VALUES ($1, $2, NOW())',
    [event.eventType, event.eventId]
  );
}
The same business event (identical eventType + eventId) only creates one delivery record — it won’t be duplicated. However, a single delivery may reach your endpoint multiple times due to retries.

Best Practices

Always verify X-Waffo-Signature. Without verification, anyone can send forged requests to your endpoint.
Production webhook URLs must use HTTPS to protect data in transit.
Return 200 immediately and process business logic in the background. Slow responses cause unnecessary retries.
Use the eventType + eventId combination to deduplicate. Ensure the same delivery processed multiple times has no side effects.
Verify that the t timestamp is within 5 minutes of the current time to prevent replay attacks.
Test and Production use different key pairs. Match the public key to the mode field in the payload.
Store received payloads for debugging. The Dashboard also provides delivery log queries.
Add a branch for every event you subscribe to, even if you don’t need it yet. Return 200 for unhandled events — returning an error triggers unnecessary retries.

Testing

Use the Dashboard “Send Test Event” button to send test events without triggering real transactions. Test events use fixed sample data (amount 0, taxAmount 0, product “[TEST] Webhook Verification”) and are always signed with the Test key. All 10 event types are supported — test each one to verify your handler.

Use Test Mode

  1. Configure the Test environment Webhook URL and events in the Dashboard
  2. Perform real operations in Test mode (create orders, process payments)
  3. Events are sent to your Test Webhook URL with Test signing keys

Local Development

Use a tunnel to expose your local server:
ngrok http 8080
# Use the generated URL as your Test Webhook URL
# e.g., https://abc123.ngrok.io/webhooks

Delivery Logs

View webhook delivery history in the Dashboard:
  • Status: pending / success / failed
  • HTTP status code: Your server’s response code
  • Response body: Your server’s response (truncated to 1000 chars)
  • Timestamp: Last delivery attempt

FAQ

Not receiving webhooks

  1. Confirm the Webhook URL is configured in the Dashboard and publicly accessible
  2. Confirm you’ve subscribed to the correct event types
  3. Confirm you’re using the correct environment (Test / Production)
  4. Check that your firewall allows requests from Waffo
  5. Try the Dashboard “Send Test Event” to isolate the issue

Signature verification fails

  1. Confirm you’re using the correct environment’s public key (Test vs Production)
  2. Confirm you’re using the raw request body — not a parsed JSON object
  3. Check if any middleware or proxy modified the request body
  4. Confirm the signature input format is ${t}.${rawBody} (timestamp + dot + raw body)
  5. If using TypeScript, switch to the @waffo/pancake-ts SDK — it handles key selection and format normalization automatically

Receiving duplicate events

This is normal retry behavior. If your endpoint returned non-2xx or timed out, the system retries. Ensure your handler is idempotent — use the eventType + eventId combination for deduplication.

Difference between subscription.canceling and subscription.canceled

  • canceling: Cancellation requested, but the current paid period hasn’t ended. The subscription is still active and the buyer can withdraw the cancellation (triggers uncanceled). Do not revoke access.
  • canceled: Subscription is terminated. This is irreversible — revoke access or downgrade permissions.

What does data.amount mean for different events?

All events: data.amount is the transaction amount for that specific event (including tax):
  • order.completed / subscription.activated — Payment amount
  • subscription.payment_succeeded — Renewal amount for this period
  • subscription.past_due — Amount due for this period
  • refund.succeeded / refund.failed — Refund amount
  • subscription.canceling / subscription.canceled / subscription.uncanceled — Subscription per-period amount