Overview
Webhooks deliver real-time notifications to your server when events occur — orders, payments, subscriptions, and refunds.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) Copy the verification public key
Register the webhook
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
Verify and handle events
Environment Isolation
Each webhook is registered for a single environment via thetestMode flag. Test and Production are fully independent:
mode field in each HTTP payload indicates the source environment: "test" or "prod".
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.
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.1on the first charge,Non 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.
0means 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_succeededit 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.uncanceledandsubscription.plan_change_failedthe 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
idoreventId. - Absent on one-time orders and their refunds, and on subscriptions and payments created before this field shipped.
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."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
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.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
TheeventId identifies the business entity that triggered the event:
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:plan_change_scheduled.Event Details
order.completed
order.completed
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)
order.completed once. Refunds are notified via refund.succeeded / refund.failed.subscription.activated
subscription.activated
pending → active).Body: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
pending → active transition. The same first payment also emits subscription.payment_succeeded for the charge itself — see Subscription Lifecycle.subscription.payment_succeeded
subscription.payment_succeeded
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.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 onsubscription.recovered
subscription.renewed
subscription.renewed
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
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.subscription.recovered
subscription.recovered
active (past_due → active). Closes the loop opened by subscription.past_due. First payments and ordinary renewals do not emit it.Body: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
eventId carries the event timestamp, so a subscription that goes overdue and recovers repeatedly produces one delivery per recovery.subscription.canceling
subscription.canceling
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
subscription.uncanceled).subscription.uncanceled
subscription.uncanceled
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
subscription.plan_changed
subscription.plan_changed
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
subscription.plan_change_scheduled
subscription.plan_change_scheduled
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
subscription.plan_change_failed
subscription.plan_change_failed
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.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
subscription.canceled
subscription.canceled
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
subscription.past_due
subscription.past_due
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
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.refund.succeeded
refund.succeeded
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”
refund.failed
refund.failed
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. Thetimestamp 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
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
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 toprocessing; 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
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:Delivery Status
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:
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
X-Waffo-Signature. Without verification, anyone can send forged requests to your endpoint.Use HTTPS
Use HTTPS
Respond fast, process async
Respond fast, process async
200 immediately and process business logic in the background. Slow responses cause unnecessary retries.Deduplicate with eventType + eventId
Deduplicate with eventType + eventId
eventType + eventId combination to deduplicate. Ensure the same delivery processed multiple times has no side effects.Check timestamps
Check timestamps
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.Use the correct environment key
Use the correct environment key
mode field in the payload.Log incoming payloads
Log incoming payloads
Handle all subscribed events
Handle all subscribed events
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.
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 (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— Amount charged for this periodsubscription.renewed/subscription.recovered— Subscription per-period amountsubscription.past_due— Amount due for this periodrefund.succeeded/refund.failed— Refund amountsubscription.canceling/subscription.canceled/subscription.uncanceled— Subscription per-period amount