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 except subscription.payment_succeeded. That one event is a pure payment event: it describes a single charge and carries neither the subscription period nor its status.

Deprecated amount fields

amount / total / subtotal / taxAmount / taxRate / taxName name one thing but describe three: the charge on a payment event, the refund on a refund event, the plan’s price on a subscription status event. Each now has a replacement that names its own subject.
On payment events, amount changes value on 2026-09-21. From that date, order.completed and subscription.payment_succeeded report the amount the payment channel actually charged — the same value as chargedAmount — where they previously reported the list price recorded when the order was placed. The two differ only when a prorated credit or a zero-amount card check applies; for every other charge the value is identical. If the channel reported no amount for that charge, amount falls back to the list price total and chargedAmount is omitted.If you reconcile on amount == total, that assertion starts failing on payment events from this date. Switch to chargedAmount (what was taken) and listPrice.total (what was listed).Nothing else changes value. On refund.* and on subscription events amount is exactly what it was, and total / subtotal / taxAmount / taxRate / taxName are unchanged on every event.
Removal is no earlier than 2027-09-20 — 12 months from this notice — and ships with the next major version. Until then all six keep being delivered with unchanged types and trigger conditions. New integrations should read the replacement fields; existing integrations need no change to keep working. The deprecation is not signalled at runtime: the payload carries no deprecation metadata, and no Deprecation or Sunset headers are sent. This page and the SDK type definitions (@deprecated in TypeScript, // Deprecated: in Go) are where it is stated.
periodNumber is the billing period number the payment channel itself reports, passed through unchanged — Waffo never computes, counts or infers it.
  • 1 on the first charge, N on the Nth renewal.
  • A failed charge still consumes a period. This is not a count of successful charges — use it to align with the period numbers you reconcile from the channel, not as a payment tally.
  • 0 means the channel has authorized the subscription but has not charged it yet. It appears on a scheduled plan change before its switch time.
  • On subscription.payment_succeeded it is the period of that charge, and it is the one subscription-shaped field that event carries.
  • On refund.* it is the period of the refunded charge, not the period the refund happened in.
  • On subscription.canceling, subscription.uncanceled and subscription.plan_change_failed the channel sends no notification of its own, so the value is the subscription’s current period as last reported by the channel.
  • Not a deduplication key. Several events in the same period carry the same number. Deduplicate on id or eventId.
  • Absent on one-time orders and their refunds, and on subscriptions and payments created before this field shipped.
The same concept appears under three names, one per anchor. They are not three different numbers — each is this same channel value read at a different point:The GraphQL subscription field is named differently on purpose: a subscription that has renewed three times is in period 3, while its first charge is forever periodNumber: 1. Reading one where you meant the other is the mistake the two names exist to prevent.
Amounts are display format strings, already converted from minor units. For example, USD "29.00" = 2900 cents; JPY "4500" = ¥4500. Zero is a legitimate value (a zero-amount card check) and is formatted the same way — "0.00" for USD, "0" for JPY. For an itemized display, use the block that matches the event: listPrice, originalPayment or planPrice.
taxRate in webhook payloads is a percentage number — 10 means 10%. The unit convention is being unified across the API surface; another surface (the TypeScript SDK types) still describes 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.

Payment amount in GraphQL

This is a defect fix, not a contract change. GraphQL’s Payment.amount has always been documented as the total charged to the buyer, but the implementation read the list price snapshot taken when the order was placed. It now reads what the payment channel actually charged — and so do amount filtering, amount sorting and the “fully refunded” test. All four read one source, so a list can no longer sort by one figure while the detail view shows another.Which payments change value. Only those where the two figures differ, which begins with the plan upgrades and downgrades that shipped on 2026-09-07 — before that date the list price and the actual charge never diverged on any charge. To date that is 7 live payments and 111 test payments. Every other payment reads exactly as it did. If you reconciled any of those charges from GraphQL after 2026-09-07, their amounts move to what the channel took, and a payment already refunded in full is now reported as fully refunded.When the channel reported no amount for a charge, the field falls back to the list price total and the fallback is logged server-side. Filtering and sorting fall back the same way, so such a payment is never silently missing from a filtered or sorted list.Webhook payloads are unaffected. No field on this page changes value; the only addition is originalChargedAmount on refund events.
Some Dashboard and Admin pages still show the list price. They read the list price snapshot directly rather than this field and have not been updated yet: the consumer invoice page, the Admin payment list, the Admin PSP reconciliation page, the merchant revenue page, the buyer portal, the sidebar, and the analytics page’s fallback. Until they are, one payment can show two different numbers — the GraphQL API and the webhook payload report what was charged, those pages report what was listed. Reconcile against the API, not the page.
A payment that collected nothing counts as fully refunded. “Fully refunded” is decided by comparing the refunds against the amount that payment collected, so a payment that collected 0 satisfies it with no refund existing at all: it reports isFullyRefunded: true and shows up in the “fully refunded” filter. These are charges that were never meant to take money — a zero-amount card check, the first charge of a free trial, or an order fully covered by a credit or coupon.Read it as the buyer was never charged, not money was returned. To tell the two apart, look for an actual refund: refundedAmount above zero, a non-empty refunds list, or refundStatus: refunded — that last one is decided purely by the existence of a succeeded refund and is unaffected by this fix.The rule itself is not new: an order whose list price was also 0 has always been reported this way. What the fix widens is the range — payments that collected 0 against a non-zero list price now join the set. That is 3 of the 7 affected live payments (39 of the 111 in test mode). No payment leaves the set because of this fix.

eventId Mapping

The eventId identifies the business entity that triggered the event:
Some events can happen more than once on the same subscription, so their eventId carries a suffix and each occurrence becomes its own delivery.Four events append the event timestamp (full ISO 8601): subscription.canceling, subscription.uncanceled, subscription.past_due and subscription.recovered — a subscriber can cancel and un-cancel, or fall overdue and recover, any number of times.subscription.renewed appends the new period end instead, so consecutive renewals each get their own key. That suffix is a date at day granularity (YYYY-MM-DD), so two period rolls inside the same calendar day would collapse into one delivery — reachable only with compressed test billing cycles, not with real weekly or monthly billing.Every other event uses the bare entity ID, because it happens at most once per entity.

Correlating a Payment with Its Billing Period

A renewal produces two independent deliveries: subscription.payment_succeeded (keyed to the charge) and subscription.renewed (keyed to the order). They leave in the same batch, but neither order nor spacing is guaranteed, so do not join them at receive time. Store each on its own, then correlate on data.orderId + data.periodNumber: the charge for period N and the renewal into period N normally carry the same pair, and the join is unaffected by reordering, delays and retries. Both values are the channel’s own period number passed through unchanged, so this consistency comes from the channel’s numbering, not from a Waffo guarantee — keep the timestamp fallback below for the rare case they disagree. The same pair ties subscription.activated to the first charge (periodNumber: 1) and subscription.recovered to the charge that cleared the overdue period. periodNumber is absent on subscriptions created before the field shipped. For those, fall back to data.orderId plus the top-level timestamp (event creation time, unchanged by retries) matched within a few minutes. In both cases deduplicate each event type on id or eventId before correlating.

Event Types

Overview

subscription.plan_changed fires when a subscription switches to another plan. direction is upgrade, downgrade, or same_price; planChange.chargedAmount is what was actually charged this time, which differs from the plan’s list price when a prorated credit or a merchant-specified amount applies.subscription.plan_change_scheduled fires when the change is confirmed but effective next period, and subscription.plan_change_failed when it does not complete. All three are live in production — handle all three, or you will miss outcomes. Which ones you receive depends on the timing tier:A next-period change therefore produces two events for one change, and the plan takes effect only on the second. Do not grant the new plan’s access on plan_change_scheduled.
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 chargedAmount here — what the channel actually took. listPrice is what the order was priced at; the two differ in this example because a prorated credit applied. No subscription or refund fields appear. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, paymentMethod, paymentLast4, chargedAmount, listPrice.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 (pending → active).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. The billing period is written and the event published inside the same request, so billingPeriod, currentPeriodStart and currentPeriodEnd are always present. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, planPrice.Recommended actions:
  • Provision the subscriber’s account and grant access
  • Record the subscription start date
Fires only on the first pending → active 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 chargedAmount; listPrice is the price the period was billed at, which a prorated credit or a zero-amount card check can make higher. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, paymentMethod, paymentLast4, chargedAmount, listPrice.
This is a pure payment event. It carries no billingPeriod, currentPeriodStart, currentPeriodEnd, canceledAt or orderStatus — those describe the subscription, not the charge, and now travel on the subscription domain events, where they are written and published inside the same request and no longer arrive empty.If your handler reads any of those five fields here:periodNumber is the exception: it describes this charge, so it stays on this event. It tells you which billing period was just paid for.A handler that only uses the payment fields and orderId needs no change.
Recommended actions:
  • Record the receipt for this charge
  • Generate an invoice for this billing cycle
  • Extend the service period on subscription.renewed, and restore full access on subscription.recovered
Trigger: The current billing period rolls forward — the paid period just ended and the next one began. The first period of a subscription is not a renewal and does not emit this event.Body:
No payment fields on this event — it reports the new period, not the charge. amount is the subscription’s per-period price. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, planPrice.Recommended actions:
  • Extend the service period to the new currentPeriodEnd
Deduplication: eventId carries the new period end, so consecutive renewals of the same subscription each get their own delivery, while a redelivered provider notification does not produce a second one.
Trigger: A retried charge succeeds on an overdue subscription and brings it back to active (past_due → active). Closes the loop opened by subscription.past_due. First payments and ordinary renewals do not emit it.Body:
No payment fields on this event — the charge itself is reported by subscription.payment_succeeded, which is delivered independently for the same retry. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, planPrice.Recommended actions:
  • Restore any access you degraded on subscription.past_due
Deduplication: eventId carries the event timestamp, so a subscription that goes overdue and recovers repeatedly produces one delivery per recovery.
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, planPrice.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, planPrice.Recommended actions:
  • Remove the “expiring soon” notice
  • Restore auto-renewal status
Trigger: Subscription product changes (upgrade or downgrade).Body:
The top-level fields describe the state after the change; data.planChange carries the before/after pair, so you do not need to keep your own record of the previous plan. direction is upgrade, downgrade, or same_price.amount is not what the buyer paid. The top-level amount / total / subtotal / taxAmount are the new plan’s list price; planChange.chargedAmount is what was actually charged this time. On a plan change the two always differ — either a prorated credit applies, or the merchant specified the amount. Reconcile against chargedAmount.planChange appears only on plan-change events; the data of every other event does not carry the key at all. Each field inside it may be null when unknown: chargedAmount is null before a successful payment, effectiveDate may be null for an immediate change, and direction falls back to null rather than passing through an unrecognized value. Omitted when empty: productDescription, merchantProvidedBuyerIdentity, billingDetail, orderMerchantExternalId, taxRate, taxName, subtotal, total, currentPeriodStart, currentPeriodEnd, planPrice.Recommended actions:
  • Update the customer’s access level (add/remove features)
  • Update billing records
Trigger: A plan change is confirmed but takes effect at the start of the next billing period. The customer keeps the current plan until then.Body:
The change has not taken effect yet. effectiveDate is the future moment the new plan starts, orderStatus is pending, and currentPeriodStart / currentPeriodEnd are absent because the new subscription has no billing period until then. chargedAmount is null — a next-period change is charged at the switch, not at confirmation. Do not grant the new plan’s access on this event; wait for subscription.plan_changed.The id and eventId are the new subscription order, which is also the order subscription.plan_changed will carry at the switch. The customer’s currently active subscription is a different order and is not named in this payload. That order is marked for the upcoming switch at this point, but subscription.canceling is not emitted for it — this event is the only signal you get.If several confirmations arrive before the switch, you receive one delivery — they collapse on eventType + eventId. The later plan_changed is a different event type and is never collapsed into it.Recommended actions:
  • Record the pending change and its effectiveDate
  • Leave the current plan’s access untouched
  • Optionally tell the customer when the new plan starts
Trigger: A plan change did not complete — the payment was not authorized, or the channel reported a failure. The original plan stays in effect.Body:
planChange is mostly null on this event, by design. Nothing took effect, so there is no direction, no effective moment, and no amounts to report: only oldPlanName and newPlanName are populated, and the other five fields are always null. A handler written against the subscription.plan_changed body will read nulls here — branch on eventType before touching those fields.
orderStatus is canceled, but the customer has not lost their subscription. The cancelled order is the new one that failed to open; the original subscription is untouched and still active. Revoking access on this event is a bug. A genuine cancellation always arrives as subscription.canceled.
A retry creates a fresh subscription order, so a second failure arrives with a different id / eventId rather than collapsing into this one.Recommended actions:
  • Leave the customer’s access exactly as it is
  • Clear any pending change you recorded from subscription.plan_change_scheduled
  • Optionally prompt the customer to try the change again
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, planPrice.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 the event timestamp as a suffix, so each time the subscription goes overdue gets its own delivery. 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, planPrice.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: One delivery per entry into the overdue state. The subscription only enters past_due again after a successful charge has brought it back to active (which emits subscription.recovered), so repeated retry failures within one overdue spell do not fan out into repeated events.
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 refundedAmount (or the deprecated 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 — the original order’s real tax is in originalPayment.taxAmount. Record the refund with refundedAmount and the order it came from with originalPayment. Compute how much of this payment is still refundable from originalChargedAmount, not from originalPayment.total — the former is what the buyer was actually charged, and refunding up to the list price is rejected by the channel whenever the two differ (a payment charged 5.00 against a 50.00 list price accepts at most 5.00). 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, refundedAmount, originalChargedAmount, originalPayment.Recommended actions:
  • Revoke delivered digital goods (revoke licenses, disable downloads)
  • Update order status to “refunded”
Trigger: Refund processing failed.Body:
No money moved. refundedAmount (and the deprecated 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 active → past_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.
Allow t up to 45 minutes old. Retries replay the original signature header — t is never re-stamped — and the final retry lands 31+ minutes after first delivery, so a 5-minute window turns your own outage recovery into 401s. Deduplicate on the payload id for replay protection instead; it is unique per event and stable across retries.
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.
periodNumber behaves differently in test events than in production.A test send has no subscription behind it, so the refund sample cannot say which period it refunds. In production a refund of a subscription charge does carry periodNumber — the period of the charge being refunded. Do not conclude from a test event that subscription refunds lack the field; code your handler to read it when present.

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 — Amount charged for this period
  • subscription.renewed / subscription.recovered — Subscription per-period amount
  • subscription.past_due — Amount due for this period
  • refund.succeeded / refund.failed — Refund amount
  • subscription.canceling / subscription.canceled / subscription.uncanceled — Subscription per-period amount