Skip to main content

Overview

Webhooks deliver real-time notifications to your server when events occur — orders, payments, subscriptions, and refunds.
A single store can have multiple webhooks, each delivering to a different channel. The available channels are: 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) 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 → 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.
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.
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: 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

Body

Top-level Fields

data Fields

The table below defines every data field — its type, when it appears, and what it means. For the exact field set of a given event, see the complete body under Event Details. In the Present column, “Payment events” means order.completed and subscription.payment_succeeded; “Subscription events” means every subscription.* event.
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.
taxRate in webhook payloads is a percentage number10 means 10%. The unit convention is being unified across the API surface; some other surfaces (the preview-tax example response, the TypeScript SDK types) still describe it as a decimal fraction. Tax collection is not enabled yet, so taxRate is 0 on every order today — read the value from the payload rather than hard-coding an assumption about its unit.

eventId Mapping

The eventId identifies the business entity that triggered the event:
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

The subscription.updated event template is ready and will activate once subscription product change ships.
For where each event sits in the order, subscription, and refund lifecycles — and for the transitions that emit nothing — see Lifecycle and Trigger Timing.

Event Details

Trigger: One-time order payment succeeds for the first time.Body:
amount equals total here. No subscription or refund fields appear. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, paymentMethod, paymentLast4.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.
Trigger: First payment on a new subscription succeeds (pendingactive).Body:
amount is the subscription’s regular-phase price, not what was charged. With a trial or an introductory discount the first charge differs — use subscription.payment_succeeded for the amount actually collected.
This event is keyed to the order, not to a charge — no paymentId or other payment fields appear. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, currentPeriodStart, currentPeriodEnd.Recommended actions:
  • Provision the subscriber’s account and grant access
  • Record the subscription start date
Fires only on the first pendingactive transition. The same first payment also emits subscription.payment_succeeded for the charge itself — see Subscription Lifecycle.
Trigger: A subscription charge succeeds — the first payment, every renewal, and the charge that clears an overdue period.Body:
Unlike the other subscription.* events, this one is keyed to a charge, so it carries the payment fields. amount is what was charged for this period and equals total. canceledAt never appears on this event even when the subscription is set to cancel. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, paymentMethod, paymentLast4, currentPeriodStart, currentPeriodEnd.Recommended actions:
  • Extend the service period
  • Generate an invoice for this billing cycle
  • If the subscription was previously past_due, restore full access
Trigger: Customer or merchant requests cancellation. The subscription remains active until the current paid period ends.Body:
canceledAt is when cancellation was requested, not when access ends. The customer has paid through currentPeriodEnd — revoking access at canceledAt cuts off service they already paid for.
No payment fields on this event. amount is the subscription’s per-period price, not a charge. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, currentPeriodStart, currentPeriodEnd.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).
Trigger: Cancellation is withdrawn before the current period ends.Body:
No payment fields on this event. canceledAt is cleared when the cancellation is withdrawn, so it does not appear. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, currentPeriodStart, currentPeriodEnd.Recommended actions:
  • Remove the “expiring soon” notice
  • Restore auto-renewal status
Trigger: Subscription product changes (upgrade or downgrade).Body:
Every field describes the state after the change — the payload carries no before/after pair, so it does not say which plan the subscription moved from. Keep your own record of the previous plan if you need to tell an upgrade from a downgrade. No payment fields on this event. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, currentPeriodStart, currentPeriodEnd.Recommended actions:
  • Update the customer’s access level (add/remove features)
  • Update billing records
Trigger: Subscription is terminated — the paid period has ended and no further renewals will occur.Body:
No payment fields on this event. canceledAt is when cancellation was requested, which is earlier than this event — the subscription stayed active until currentPeriodEnd. amount is the per-period price, not a final charge. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, currentPeriodStart, currentPeriodEnd, canceledAt.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.
Trigger: Renewal payment fails and the subscription enters an overdue state.Body:
eventId carries a -YYYY-MM suffix — this is the only event whose eventId differs from id, and it is what limits the event to once per calendar month. No payment fields appear, so the payload does not say why the charge failed; amount is the amount due. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, currentPeriodStart, currentPeriodEnd.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.
Trigger: Refund has been completed and funds returned.Body (partial refund: 10.00 refunded against a 29.00 original payment):
amount is the refund amount; total is the original payment. On a partial refund they differ — record the refund with amount, never total.
subtotal / total / taxRate / taxName describe the original payment and are kept for comparison. taxAmount is always "0" because refunds carry no tax breakdown; for tax detail, look at the original order. paymentId identifies the charge being refunded — for a subscription this is the specific renewal, which is how you tell which period was refunded. The payload does not report how much of the payment has been refunded in total, so track that yourself if a payment can be refunded more than once. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, refundTicketMerchantExternalId, taxRate, taxName, subtotal, total, paymentMethod, paymentLast4, refundReason.Recommended actions:
  • Revoke delivered digital goods (revoke licenses, disable downloads)
  • Update order status to “refunded”
Trigger: Refund processing failed.Body:
No money moved. amount is the amount that was attempted — do not record it as a refund.
Same shape as refund.succeeded otherwise. refundStatus is the only field that distinguishes the two events, so branch on it rather than on the presence of any field. The payload does not carry a failure cause; check the refund ticket in the Dashboard. Omitted when empty: same list as refund.succeeded.Recommended actions:
  • Log the failure for manual review
  • Do not revoke goods (the refund was not completed)

Lifecycle and Trigger Timing

An event fires when the underlying business fact is settled — not when the buyer clicks. Payment events are emitted after the payment provider confirms the charge; subscription state events are emitted after the provider confirms the state change. Delivery to your endpoint then normally completes within a few seconds. The timestamp field carries the moment the event was created. Retries reuse it, so it does not move with delivery attempts.

Delivery Timeline

Treat the event — not the checkout redirect — as the point where money is confirmed. A buyer can close the browser before the redirect; the event still arrives.

One-Time Order Lifecycle

A declined payment leaves the order in pending, so the buyer can pay again on the same order. order.completed fires once per order — the retry that finally succeeds is the one that emits it.

Subscription Lifecycle

The first payment emits two events: subscription.activated (keyed to the order) and subscription.payment_succeeded (keyed to the charge). They are delivered independently, so neither order nor spacing is guaranteed. Provision access on activated and record the receipt on payment_succeeded — handling both as “a new subscriber” runs your onboarding twice.
Cancellation splits into two moments. subscription.canceling fires the instant cancellation is requested; the termination itself is scheduled for the end of the paid period, and subscription.canceled fires once the payment provider confirms it — around currentPeriodEnd, not at the moment of the request. Between the two, the subscription is still active and the customer can withdraw the cancellation. An overdue subscription is terminated by the next failed renewal: the first decline moves activepast_due and emits subscription.past_due; a further decline in past_due ends the subscription and emits subscription.canceled.

Refund Lifecycle

Refund events fire at the last step only — when the provider settles the outcome. Creating, approving, rejecting, or resubmitting a refund ticket produces no webhook, so a ticket sitting in review is invisible to your endpoint until it settles. Refunds requested through the API are auto-approved and go straight to processing; see Refund Endpoints for the ticket states and how to query them.

When No Event Fires


Signature Verification

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

Algorithm

The SDK embeds public keys, auto-detects the environment, and handles format normalization:
See the full SDK Webhook documentation.

Manual Verification

If you’re not using the TypeScript SDK, implement signature verification manually.
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
Non-2xx responses or timeouts trigger retries.

Retry Policy

Failed deliveries are retried automatically with exponential backoff:

Delivery Status

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

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.

Why does the first subscription payment produce two events?

The first charge settles two facts at once: the subscription becomes active (subscription.activated, keyed to the order) and a payment was collected (subscription.payment_succeeded, keyed to the charge). They travel independently, so neither order nor spacing is guaranteed. Provision access on activated, record the receipt on payment_succeeded, and your first-payment path runs exactly once.

When exactly does subscription.canceled arrive?

Around currentPeriodEnd — not when the cancellation was requested. Requesting cancellation emits subscription.canceling immediately and schedules the termination for the end of the paid period; subscription.canceled follows once the payment provider confirms the termination. Use currentPeriodEnd from the canceling payload to know when access should end.

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