> ## 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.

# Webhooks

> Receive real-time event notifications

## 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:

| Channel    | Delivery target                    | Payload format                  |
| ---------- | ---------------------------------- | ------------------------------- |
| `http`     | Your HTTPS endpoint                | RSA-SHA256-signed JSON envelope |
| `feishu`   | Lark / Feishu bot incoming webhook | Interactive card (Lark format)  |
| `discord`  | Discord channel webhook            | Embed message (Discord format)  |
| `telegram` | Telegram bot `sendMessage` URL     | HTML-formatted text             |
| `slack`    | Slack incoming webhook             | Attachment 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.

<Tip>
  **Using TypeScript?** The [`@waffo/pancake-ts`](https://www.npmjs.com/package/@waffo/pancake-ts) SDK has built-in public keys and auto-detects the environment — one line to verify the `http` channel.
</Tip>

***

## Setup

<Steps>
  <Step title="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.
  </Step>

  <Step title="(HTTP only) Copy the verification public key">
    Waffo uses one fixed key pair per environment — Test and Production — shared across all stores. You don't get a key back when registering a webhook; you read it from the Dashboard.

    Open the [Dashboard](https://pancake.waffo.ai/merchant/dashboard) → any store → **Settings → Webhooks**, and copy the **Webhook Public Key** for the environment you're integrating (Test or Production). Store it in your server's config; you'll use it to verify every incoming HTTP webhook.

    <Note>Every store's Dashboard shows the same Test key and the same Production key — they're platform-level. Adding, editing, or deleting webhook URLs does not change them.</Note>
  </Step>

  <Step title="Register the webhook">
    Add a webhook in **Dashboard → Settings → Webhooks**, or call [`POST /v1/actions/store/add-webhook`](/api-reference/endpoints/webhooks/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.
  </Step>

  <Step title="Send a test event">
    Use the Dashboard "Send Test Event" button to deliver a sample event to one or all of your registered webhooks.
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

## Environment Isolation

Each webhook is registered for a single environment via the `testMode` flag. Test and Production are fully independent:

| Aspect                              | Test                         | Production                                |
| ----------------------------------- | ---------------------------- | ----------------------------------------- |
| Webhook record                      | `testMode: true`             | `testMode: false`                         |
| Signing key (HTTP only)             | Test key pair                | Production key pair                       |
| Verification public key (HTTP only) | Dashboard Test key           | Dashboard Production key                  |
| Selector when delivering            | Header `X-Environment: test` | Header `X-Environment: prod` (or omitted) |

The `mode` field in each HTTP payload indicates the source environment: `"test"` or `"prod"`.

<Warning>
  Always use the public key matching the event's `mode`. A Test key cannot verify Production events, and vice versa.
</Warning>

***

## Payload Format

### Headers

| Header              | Description                                      |
| ------------------- | ------------------------------------------------ |
| `Content-Type`      | `application/json`                               |
| `X-Waffo-Signature` | Signature string: `t=<timestamp>,v1=<signature>` |
| `X-Waffo-Event`     | Event type (e.g., `order.completed`)             |

### Body

```json theme={"system"}
{
  "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": "customer@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" },
    "orderMerchantExternalId": "ORDER-2026-00891",
    "productMetadata": {},
    "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
    "paymentStatus": "succeeded",
    "paymentMethod": "card",
    "paymentLast4": "4242",
    "paymentDate": "2026-03-10"
  }
}
```

### Top-level Fields

| Field       | Type   | Description                                                                                                     |
| ----------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| `id`        | string | Event entity ID — same as `eventId` for most events                                                             |
| `timestamp` | string | Event time (ISO 8601 UTC)                                                                                       |
| `eventType` | string | Event type (see [Event Types](#event-types))                                                                    |
| `eventId`   | string | Business event identifier — maps to different entities per event type (see [eventId Mapping](#eventid-mapping)) |
| `storeId`   | string | Store ID                                                                                                        |
| `storeName` | string | Store name                                                                                                      |
| `mode`      | string | `"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:**

| Field             | Type   | Description                                                                   |
| ----------------- | ------ | ----------------------------------------------------------------------------- |
| `orderId`         | string | Order ID                                                                      |
| `orderStatus`     | string | Order status (e.g., `"completed"`, `"active"`, `"canceling"`)                 |
| `buyerEmail`      | string | Customer email address                                                        |
| `currency`        | string | ISO 4217 currency code (e.g., `"USD"`, `"JPY"`)                               |
| `amount`          | string | Transaction amount including tax (display format, e.g., `"29.00"`)            |
| `taxAmount`       | string | Tax amount (display format, e.g., `"2.90"`)                                   |
| `productName`     | string | Product name                                                                  |
| `orderMetadata`   | object | Order-level metadata from checkout session (merchant-defined key-value pairs) |
| `productMetadata` | object | Product-level metadata set when creating/updating the product                 |

**Included when available:**

| Field                            | Type   | Present when                  | Description                                                                                                                                                                |
| -------------------------------- | ------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `merchantProvidedBuyerIdentity`  | string | Set at checkout               | Merchant's custom customer identifier                                                                                                                                      |
| `orderMerchantExternalId`        | string | Set at checkout               | Order's merchant business-side identifier (set at checkout creation, max 128 chars). Present on order events and on `refund.*` events (inherited from the original order). |
| `refundTicketMerchantExternalId` | string | Set on refund ticket creation | Refund ticket's merchant business-side identifier (max 128 chars). Only present on `refund.succeeded` / `refund.failed` events.                                            |
| `billingDetail`                  | object | Set at checkout               | Billing address (`country`, `isBusiness`, etc.)                                                                                                                            |
| `taxRate`                        | number | Tax applied                   | Tax rate as decimal (e.g., `0.1` for 10%)                                                                                                                                  |
| `taxName`                        | string | Tax applied                   | Tax name (e.g., `"Consumption Tax"`)                                                                                                                                       |
| `subtotal`                       | string | Available                     | Subtotal before tax (display format)                                                                                                                                       |
| `total`                          | string | Available                     | Total after tax (display format)                                                                                                                                           |
| `productDescription`             | string | Set on product                | Product description                                                                                                                                                        |

**Payment events** (`order.completed`, `subscription.payment_succeeded`):

| Field                  | Type   | Description                                        |
| ---------------------- | ------ | -------------------------------------------------- |
| `paymentId`            | string | Payment ID                                         |
| `paymentStatus`        | string | Payment status (`"succeeded"`, `"failed"`)         |
| `paymentMethod`        | string | Payment method type (e.g., `"card"`)               |
| `paymentLast4`         | string | Last 4 digits of payment instrument                |
| `paymentDate`          | string | Payment date (ISO 8601 date, e.g., `"2026-03-10"`) |
| `paymentFailureReason` | string | Failure reason (when payment failed)               |

**Subscription events** (`subscription.*`):

| Field                | Type   | Description                                                        |
| -------------------- | ------ | ------------------------------------------------------------------ |
| `billingPeriod`      | string | `"weekly"`, `"monthly"`, `"quarterly"`, `"yearly"`                 |
| `currentPeriodStart` | string | Current billing period start (ISO 8601 date)                       |
| `currentPeriodEnd`   | string | Current billing period end (ISO 8601 date)                         |
| `canceledAt`         | string | Cancellation timestamp (ISO 8601, present when canceling/canceled) |

**Refund events** (`refund.succeeded`, `refund.failed`):

| Field                            | Type   | Description                                                                             |
| -------------------------------- | ------ | --------------------------------------------------------------------------------------- |
| `refundStatus`                   | string | `"succeeded"` or `"failed"`                                                             |
| `refundReason`                   | string | Refund reason                                                                           |
| `refundCreatedAt`                | string | Refund creation timestamp (ISO 8601)                                                    |
| `orderMerchantExternalId`        | string | Order's merchant business-side identifier (inherited from the original order, when set) |
| `refundTicketMerchantExternalId` | string | Refund ticket's merchant business-side identifier (when set on ticket creation)         |
| `paymentId`                      | string | Original payment ID                                                                     |
| `paymentStatus`                  | string | Original payment status                                                                 |
| `paymentMethod`                  | string | Original payment method                                                                 |
| `paymentLast4`                   | string | Original payment last 4 digits                                                          |
| `paymentDate`                    | string | Original payment date                                                                   |

Example `data` for a `refund.succeeded` event (only the refund-specific fields shown):

```json theme={"system"}
{
  "refundStatus": "succeeded",
  "refundReason": "Customer requested refund",
  "refundCreatedAt": "2026-03-12T09:15:00.000Z",
  "orderMerchantExternalId": "ORDER-2026-00891",
  "refundTicketMerchantExternalId": "REF-2026-00891",
  "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "paymentStatus": "succeeded",
  "paymentMethod": "card",
  "paymentLast4": "4242",
  "paymentDate": "2026-03-10"
}
```

<Note>
  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.
</Note>

### eventId Mapping

The `eventId` identifies the business entity that triggered the event:

| Event Type                       | eventId maps to  | Example                              |
| -------------------------------- | ---------------- | ------------------------------------ |
| `order.completed`                | Payment ID       | `PAY_6eYCunG3IMmIgcQOnaXdoA`         |
| `subscription.activated`         | Order ID         | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.payment_succeeded` | Payment ID       | `PAY_6eYCunG3IMmIgcQOnaXdoA`         |
| `subscription.canceling`         | Order ID         | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.uncanceled`        | Order ID         | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.updated`           | Order ID         | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.canceled`          | Order ID         | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.past_due`          | Order ID + month | `ORD_5dXBtmF2HLlHfbPNm0Wcnz-2026-04` |
| `refund.succeeded`               | Refund ID        | `REF_4cWAtlE1GKkGebONl9Xbnx`         |
| `refund.failed`                  | Refund ID        | `REF_4cWAtlE1GKkGebONl9Xbnx`         |

<Note>
  `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.
</Note>

***

## Event Types

### Overview

| Event                            | Trigger                                           | eventId          |
| -------------------------------- | ------------------------------------------------- | ---------------- |
| `order.completed`                | One-time order payment succeeded                  | Payment ID       |
| `subscription.activated`         | Subscription first payment succeeded              | Order ID         |
| `subscription.payment_succeeded` | Renewal payment succeeded (not first)             | Payment ID       |
| `subscription.canceling`         | Cancellation requested — active until period ends | Order ID         |
| `subscription.uncanceled`        | Cancellation withdrawn                            | Order ID         |
| `subscription.updated`           | Product changed (upgrade/downgrade)               | Order ID         |
| `subscription.canceled`          | Subscription terminated (period ended)            | Order ID         |
| `subscription.past_due`          | Renewal payment failed                            | Order ID + month |
| `refund.succeeded`               | Refund completed                                  | Refund ID        |
| `refund.failed`                  | Refund failed                                     | Refund ID        |

<Note>
  `subscription.uncanceled` and `subscription.updated` event templates are ready and will activate once the corresponding features launch.
</Note>

### Event Details

<AccordionGroup>
  <Accordion title="order.completed" icon="check">
    **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 customer 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`.
  </Accordion>

  <Accordion title="subscription.activated" icon="play">
    **Trigger**: First payment on a new subscription succeeds (`pending` → `active`).

    **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`.
  </Accordion>

  <Accordion title="subscription.payment_succeeded" icon="credit-card">
    **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
  </Accordion>

  <Accordion title="subscription.canceling" icon="clock">
    **Trigger**: Customer 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 customer has paid for the current period

    The customer can withdraw the cancellation before the period ends (triggers `subscription.uncanceled`).
  </Accordion>

  <Accordion title="subscription.uncanceled" icon="rotate-left">
    **Trigger**: Cancellation is withdrawn before the current period ends.

    **Recommended actions**:

    * Remove the "expiring soon" notice
    * Restore auto-renewal status
  </Accordion>

  <Accordion title="subscription.updated" icon="arrows-rotate">
    **Trigger**: Subscription product changes (upgrade or downgrade).

    **Payload**:

    * `data.productName` — New product name after the change
    * `data.amount` — New amount

    **Recommended actions**:

    * Update the customer's access level (add/remove features)
    * Update billing records
  </Accordion>

  <Accordion title="subscription.canceled" icon="xmark">
    **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 customer re-subscribes)
    * Send a "subscription ended" confirmation

    This is a terminal state. The subscription is irreversibly ended.
  </Accordion>

  <Accordion title="subscription.past_due" icon="triangle-exclamation">
    **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 customer 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.
  </Accordion>

  <Accordion title="refund.succeeded" icon="money-bill-transfer">
    **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"
  </Accordion>

  <Accordion title="refund.failed" icon="circle-exclamation">
    **Trigger**: Refund processing failed.

    **Recommended actions**:

    * Log the failure for manual review
    * Do not revoke goods (the refund was not completed)
  </Accordion>
</AccordionGroup>

### Subscription Lifecycle

```mermaid theme={"system"}
stateDiagram-v2
    [*] --> pending: Order created
    pending --> active: First payment succeeds<br/>subscription.activated
    pending --> closed: Payment timeout

    active --> canceling: Cancellation requested<br/>subscription.canceling
    active --> past_due: Renewal failed<br/>subscription.past_due
    active --> active: Renewal succeeded<br/>subscription.payment_succeeded
    active --> active: Product changed<br/>subscription.updated

    canceling --> active: Cancellation withdrawn<br/>subscription.uncanceled
    canceling --> canceled: Period ended<br/>subscription.canceled

    past_due --> active: Overdue payment recovered<br/>subscription.payment_succeeded
    past_due --> canceled: Final cancellation<br/>subscription.canceled

    active --> expired: Term ended

    canceled --> [*]
    closed --> [*]
    expired --> [*]
```

| Terminal State | Meaning                                                       |      Fires Webhook?     |
| -------------- | ------------------------------------------------------------- | :---------------------: |
| `canceled`     | Subscription terminated (customer/merchant cancel or overdue) | `subscription.canceled` |
| `closed`       | Never activated — payment timed out                           |            No           |
| `expired`      | Fixed-term subscription naturally ended                       |            No           |

***

## 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
```

### Using the SDK (Recommended)

The SDK embeds public keys, auto-detects the environment, and handles format normalization:

```typescript theme={"system"}
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](/integrate/webhooks).

### Manual Verification

If you're not using the TypeScript SDK, implement signature verification manually.

<CodeGroup>
  ```javascript Node.js (Express) theme={"system"}
  const crypto = require('crypto');

  // From Dashboard → API & Development → 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;
    }
  }
  ```

  ```python Python (Flask) theme={"system"}
  import json, time
  from base64 import b64decode
  from cryptography.hazmat.primitives import hashes, serialization
  from cryptography.hazmat.primitives.asymmetric import padding
  from flask import Flask, request

  app = Flask(__name__)

  WAFFO_WEBHOOK_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----"""

  def verify_webhook_signature(raw_body, signature_header, public_key_pem):
      parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
      t, v1 = parts.get("t"), parts.get("v1")
      if not t or not v1:
          return False

      # Replay protection: 5-minute tolerance
      if abs(int(time.time() * 1000) - int(t)) > 5 * 60 * 1000:
          return False

      signature_input = f"{t}.{raw_body}".encode("utf-8")
      public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
      try:
          public_key.verify(b64decode(v1), signature_input, padding.PKCS1v15(), hashes.SHA256())
          return True
      except Exception:
          return False

  @app.route("/webhooks", methods=["POST"])
  def handle_webhook():
      sig = request.headers.get("X-Waffo-Signature", "")
      raw_body = request.get_data(as_text=True)

      if not verify_webhook_signature(raw_body, sig, WAFFO_WEBHOOK_PUBLIC_KEY):
          return "Invalid signature", 401

      event = json.loads(raw_body)
      # Process event...
      return "OK", 200
  ```

  ```go Go (net/http) theme={"system"}
  package main

  import (
  	"crypto"
  	"crypto/rsa"
  	"crypto/sha256"
  	"crypto/x509"
  	"encoding/base64"
  	"encoding/pem"
  	"fmt"
  	"io"
  	"math"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  const waffoPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----`

  func verifyWebhookSignature(rawBody []byte, signatureHeader string) bool {
  	var t, v1 string
  	for _, pair := range strings.Split(signatureHeader, ",") {
  		parts := strings.SplitN(pair, "=", 2)
  		if len(parts) != 2 { continue }
  		switch strings.TrimSpace(parts[0]) {
  		case "t": t = strings.TrimSpace(parts[1])
  		case "v1": v1 = strings.TrimSpace(parts[1])
  		}
  	}
  	if t == "" || v1 == "" { return false }

  	ts, err := strconv.ParseInt(t, 10, 64)
  	if err != nil || math.Abs(float64(time.Now().UnixMilli()-ts)) > 5*60*1000 {
  		return false
  	}

  	block, _ := pem.Decode([]byte(waffoPublicKeyPEM))
  	if block == nil { return false }
  	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
  	if err != nil { return false }
  	rsaPub, ok := pub.(*rsa.PublicKey)
  	if !ok { return false }

  	hash := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", t, string(rawBody))))
  	sig, err := base64.StdEncoding.DecodeString(v1)
  	if err != nil { return false }
  	return rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, hash[:], sig) == nil
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	rawBody, _ := io.ReadAll(r.Body)
  	if !verifyWebhookSignature(rawBody, r.Header.Get("X-Waffo-Signature")) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}
  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte("OK"))
  }

  func main() {
  	http.HandleFunc("/webhooks", webhookHandler)
  	http.ListenAndServe(":8080", nil)
  }
  ```
</CodeGroup>

<Warning>
  **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.
</Warning>

***

## Response Requirements

* Return **2xx** status code (recommended: `200`)
* Respond within **10 seconds**
* Response body does not matter

```javascript theme={"system"}
// 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:

| Item          | Details                                                 |
| ------------- | ------------------------------------------------------- |
| Retries       | Up to 3 (4 total attempts including the first)          |
| Strategy      | Exponential backoff                                     |
| Timeout       | Non-2xx or no response within the timeout window        |
| Final failure | Delivery marked as `failed` after all retries exhausted |

### Delivery Status

| Status    | Description                                       |
| --------- | ------------------------------------------------- |
| `pending` | Created, awaiting delivery or retrying            |
| `success` | Delivered successfully (your server returned 2xx) |
| `failed`  | All 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:

```javascript theme={"system"}
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]
  );
}
```

<Note>
  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.
</Note>

***

## Best Practices

<AccordionGroup>
  <Accordion title="Always verify signatures">
    Always verify `X-Waffo-Signature`. Without verification, anyone can send forged requests to your endpoint.
  </Accordion>

  <Accordion title="Use HTTPS">
    Production webhook URLs must use HTTPS to protect data in transit.
  </Accordion>

  <Accordion title="Respond fast, process async">
    Return `200` immediately and process business logic in the background. Slow responses cause unnecessary retries.
  </Accordion>

  <Accordion title="Deduplicate with eventType + eventId">
    Use the `eventType` + `eventId` combination to deduplicate. Ensure the same delivery processed multiple times has no side effects.
  </Accordion>

  <Accordion title="Check timestamps">
    Verify that the `t` timestamp is within 5 minutes of the current time to prevent replay attacks.
  </Accordion>

  <Accordion title="Use the correct environment key">
    Test and Production use different key pairs. Match the public key to the `mode` field in the payload.
  </Accordion>

  <Accordion title="Log incoming payloads">
    Store received payloads for debugging. The Dashboard also provides delivery log queries.
  </Accordion>

  <Accordion title="Handle all subscribed events">
    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.
  </Accordion>
</AccordionGroup>

***

## Testing

### Send Test Events (Recommended)

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:

```bash theme={"system"}
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`](https://www.npmjs.com/package/@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 customer 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
