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.| 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 |
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.
Setup
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.
(HTTP only) Get the verification public key
Go to Dashboard → Settings → Webhooks and copy the Webhook Public Key for the target environment (Test / Production).
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.Send a test event
Use the Dashboard “Send Test Event” button to deliver a sample event to one or all of your registered webhooks.
Environment Isolation
Each webhook is registered for a single environment via thetestMode 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) |
mode field in each HTTP payload indicates the source environment: "test" or "prod".
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
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) |
eventId | string | Business event identifier — maps to different entities per event type (see 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 | Buyer 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 |
| Field | Type | Present when | Description |
|---|---|---|---|
merchantProvidedBuyerIdentity | string | Set at checkout | Merchant’s custom buyer identifier |
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 |
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.*):
| 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.succeeded, refund.failed):
| Field | Type | Description |
|---|---|---|
refundStatus | string | "succeeded" or "failed" |
refundReason | string | Refund reason |
refundCreatedAt | string | Refund creation timestamp (ISO 8601) |
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 |
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
TheeventId 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 |
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
| 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 |
subscription.uncanceled and subscription.updated event templates are ready and will activate once the corresponding features launch.Event Details
order.completed
order.completed
Trigger: One-time order payment succeeds for the first time.Payload:
data.amount— Payment amount (including tax)data.orderId— The one-time order ID
- 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)
order.completed once. Refunds are notified via refund.succeeded / refund.failed.subscription.activated
subscription.activated
Trigger: First payment on a new subscription succeeds (
pending → active).Payload:data.amount— First payment amount (including tax)data.productName— Subscription product name
- Provision the subscriber’s account and grant access
- Record the subscription start date
pending to active for the first time. Subsequent renewals use subscription.payment_succeeded.subscription.payment_succeeded
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
- Extend the service period
- Generate an invoice for this billing cycle
- If the subscription was previously
past_due, restore full access
subscription.canceling
subscription.canceling
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
subscription.uncanceled).subscription.uncanceled
subscription.uncanceled
Trigger: Cancellation is withdrawn before the current period ends.Recommended actions:
- Remove the “expiring soon” notice
- Restore auto-renewal status
subscription.updated
subscription.updated
Trigger: Subscription product changes (upgrade or downgrade).Payload:
data.productName— New product name after the changedata.amount— New amount
- Update the buyer’s access level (add/remove features)
- Update billing records
subscription.canceled
subscription.canceled
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
subscription.past_due
subscription.past_due
Trigger: Renewal payment fails and the subscription enters an overdue state.Payload:
data.amount— The amount due for this periodeventId— Format:{orderId}-YYYY-MM(monthly dedup)
- 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
past_due event per subscription per calendar month. If still overdue next month, a new event fires.refund.succeeded
refund.succeeded
Trigger: Refund has been completed and funds returned.Payload:
data.amount— Refund amount (including tax)data.orderId— Original order ID
- Revoke delivered digital goods (revoke licenses, disable downloads)
- Update order status to “refunded”
refund.failed
refund.failed
Trigger: Refund processing failed.Recommended actions:
- Log the failure for manual review
- Do not revoke goods (the refund was not completed)
Subscription Lifecycle
| Terminal State | Meaning | Fires Webhook? |
|---|---|---|
canceled | Subscription terminated (buyer/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
Using the SDK (Recommended)
The SDK embeds public keys, auto-detects the environment, and handles format normalization:Manual Verification
If you’re not using the TypeScript SDK, implement signature verification manually.Response Requirements
- Return 2xx status code (recommended:
200) - Respond within 10 seconds
- Response body does not matter
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 |
Handling Duplicates
Network issues may cause the same event to be delivered multiple times. Ensure your event handling is idempotent. Use theeventType + eventId combination (which has a unique constraint in the system) for deduplication:
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 signatures
Always verify signatures
Always verify
X-Waffo-Signature. Without verification, anyone can send forged requests to your endpoint.Use HTTPS
Use HTTPS
Production webhook URLs must use HTTPS to protect data in transit.
Respond fast, process async
Respond fast, process async
Return
200 immediately and process business logic in the background. Slow responses cause unnecessary retries.Deduplicate with eventType + eventId
Deduplicate with eventType + eventId
Use the
eventType + eventId combination to deduplicate. Ensure the same delivery processed multiple times has no side effects.Check timestamps
Check timestamps
Verify that the
t timestamp is within 5 minutes of the current time to prevent replay attacks.Use the correct environment key
Use the correct environment key
Test and Production use different key pairs. Match the public key to the
mode field in the payload.Log incoming payloads
Log incoming payloads
Store received payloads for debugging. The Dashboard also provides delivery log queries.
Handle all subscribed events
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.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
- Configure the Test environment Webhook URL and events in the Dashboard
- Perform real operations in Test mode (create orders, process payments)
- Events are sent to your Test Webhook URL with Test signing keys
Local Development
Use a tunnel to expose your local server: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
- Confirm the Webhook URL is configured in the Dashboard and publicly accessible
- Confirm you’ve subscribed to the correct event types
- Confirm you’re using the correct environment (Test / Production)
- Check that your firewall allows requests from Waffo
- Try the Dashboard “Send Test Event” to isolate the issue
Signature verification fails
- Confirm you’re using the correct environment’s public key (Test vs Production)
- Confirm you’re using the raw request body — not a parsed JSON object
- Check if any middleware or proxy modified the request body
- Confirm the signature input format is
${t}.${rawBody}(timestamp + dot + raw body) - If using TypeScript, switch to the
@waffo/pancake-tsSDK — 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 theeventType + 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 (triggersuncanceled). 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 amountsubscription.payment_succeeded— Renewal amount for this periodsubscription.past_due— Amount due for this periodrefund.succeeded/refund.failed— Refund amountsubscription.canceling/subscription.canceled/subscription.uncanceled— Subscription per-period amount