Churn Buster
Dunning Cancel Flows Results Pricing
Sign In Book a Tour
Churn Buster
Dunning Cancel Flows Results Pricing Sign In Book a Tour
API Integration / Webhook Data Reference
Dunning
Adaptive Campaigns Emails SMS Nudge Last Chance Offers Retries Card Updates Alerts Data & Testing Working With Our Team Churn Buster MCP
Cancel Flows
Overview Getting Started Implementation Deflections Cancel Reasons Targeted Offers Custom Branding Segmentation Analytics & Insights
Integrations
Stripe Loop Inveterate Ordergroove Rivo Checkout Champ Skio Stay AI Seal Subscriptions Awtomic Recharge Smartrr PayPal Braintree Orb API
Getting Started with the Churn Buster API Successful Payments API Endpoint Failed Payments API Endpoint Cancellations API Endpoint Default Payment Method API Endpoint (always optional) Customer Endpoint Upcoming Annual Renewals API Endpoint Webhook Data Reference
Other FAQ
Dunning
Adaptive Campaigns Emails SMS Nudge Last Chance Offers Retries Card Updates Alerts Data & Testing Working With Our Team Churn Buster MCP
Cancel Flows
Overview Getting Started Implementation Deflections Cancel Reasons Targeted Offers Custom Branding Segmentation Analytics & Insights
Integrations
Stripe Loop Inveterate Ordergroove Rivo Checkout Champ Skio Stay AI Seal Subscriptions Awtomic Recharge Smartrr PayPal Braintree Orb API
Getting Started with the Churn Buster API Successful Payments API Endpoint Failed Payments API Endpoint Cancellations API Endpoint Default Payment Method API Endpoint (always optional) Customer Endpoint Upcoming Annual Renewals API Endpoint Webhook Data Reference
Other FAQ
Docs  /  API Integration

Webhook Data Reference

What Churn Buster sends in outbound webhooks: payload shape, event types, and how to consume them.

Churn Buster sends webhooks for six event types. Every payload has the same top-level structure, so one consumer can handle all of them.

Event types

Event Category Fires when
campaign_started Dunning A failed payment triggers a new dunning campaign
campaign_won Dunning The payment is recovered
campaign_lost Dunning The campaign ends without recovery; the customer churns
cancel_session_won Cancel Flow The customer accepts a cancel flow offer; sends once per session
cancel_session_lost Cancel Flow The customer completes cancellation
email_bounced Email A campaign email fails delivery

Payload structure

{
  "event_type": "campaign_won",
  "event": {
    "customer": { ... },
    "campaign": { ... }
  }
}

The contents of event depend on the event type:

  • Dunning events (campaign_started, campaign_won, campaign_lost) include campaign and customer.
  • Cancel flow events (cancel_session_won, cancel_session_lost) include cancel_session and customer.
  • email_bounced includes campaign, bounce, and customer.

Verifying webhook signatures

Churn Buster signs every webhook for every account. The X-Churn-Buster-Signature header uses the format t=<unix timestamp>,v1=<signature>. The signature is a hex-encoded HMAC-SHA256 of <timestamp>.<raw JSON body>.

Find your signing keys under Settings → Integrations → Webhooks → Webhook security. Live Mode and Test Mode have separate keys, starting with whsec_live_ and whsec_test_. Use the key for the mode that sent the webhook.

  1. Read the signature header and split out the t timestamp and v1 signature.
  2. Combine t, a period, and the raw body of the request. Don't parse and serialize the JSON again; that can change the bytes used to compute the signature.
  3. Compute the HMAC-SHA256 with your signing key, hex-encode the result, and use a constant-time comparison to check it against v1. Reject the request if they don't match.

You can also reject old timestamps. Churn Buster refreshes the timestamp on every retry.

Customer object

Present in all event types.

{
  "churn_buster_id": "uuid",
  "email": "customer@example.com",
  "processor": "stripe",
  "processor_id": "cus_000000001",
  "properties": {
    "first_name": "Sarah",
    "last_name": "Jones"
  }
}
  • churn_buster_id (UUID): unique Churn Buster customer identifier
  • email (string): customer email address
  • processor (string): payment processor name, e.g. "stripe", "braintree", "recharge"
  • processor_id (string): customer ID in your payment processor
  • properties (object): custom customer attributes sent to Churn Buster; contents vary by account
  • capture_url (string): shareable URL where the customer can update their payment method

Campaign object

Present in: campaign_started, campaign_won, campaign_lost, email_bounced.

{
  "amount": {
    "cents": 4999,
    "currency": "USD",
    "formatted": "$49.99"
  },
  "churn_buster_id": "uuid",
  "started_at": "2024-01-15T10:30:00Z",
  "finished_at": "2024-01-18T14:20:00Z",
  "processor": "stripe",
  "processor_id": "ch_000000001",
  "properties": {}
}
  • amount.cents (integer): failed payment amount in cents
  • amount.currency (string): ISO 4217 currency code
  • amount.formatted (string): human-readable amount with currency symbol
  • churn_buster_id (UUID): unique campaign identifier
  • started_at (ISO 8601): when the campaign began
  • finished_at (ISO 8601): when the campaign ended; only present in campaign_won and campaign_lost events
  • processor (string): payment processor name
  • processor_id (string): failed payment/charge ID in the processor
  • properties (object): custom campaign properties

Cancel session object

Present in: cancel_session_won, cancel_session_lost.

{
  "amount": {
    "cents": 2999,
    "currency": "USD",
    "formatted": "$29.99"
  },
  "churn_buster_id": "uuid",
  "processor_subscription_id": "sub_000000001",
  "cancel_flow": "Default Cancel Flow",
  "started_at": "2024-01-15T10:30:00Z",
  "finished_at": "2024-01-15T10:35:00Z",
  "properties": {}
}
  • amount.cents (integer): subscription amount in cents
  • amount.currency (string): ISO 4217 currency code
  • amount.formatted (string): human-readable amount with currency symbol
  • churn_buster_id (UUID): unique cancel session identifier
  • processor_subscription_id (string): subscription ID in your payment processor
  • cancel_flow (string): name of the cancel flow that was presented
  • started_at (ISO 8601): when the cancel session began
  • finished_at (ISO 8601): when the session ended (offer accepted or cancellation completed)
  • properties (object): custom session properties

cancel_session_won events also include an offer object. cancel_session_lost events also include:

  • reason (string): the cancellation reason the customer selected
  • feedback (object): feedback and sentiment analysis, if available; may be empty

Offer object (cancel_session_won only)

{
  "offer_id": "uuid",
  "offer_name": "10% Off Next 3 Months",
  "offer_type": "discount",
  "offer_variant_id": "uuid",
  "offer_variant_name": "Variant A",
  "discount_code": "SAVE10"
}
  • offer_id (UUID): unique offer identifier
  • offer_name (string): display name of the offer
  • offer_type (string): one of: custom, skip, discount, swap, skip_and_discount, pause, presentation
  • offer_variant_id (UUID): unique variant identifier (for A/B testing)
  • offer_variant_name (string): display name of the variant
  • discount_code (string): only present for discount and skip_and_discount offer types
  • swap_variant_id (string): only present for swap offer types; the external product variant ID

Feedback object (cancel_session_lost only)

Present when feedback was collected. May be an empty object if the customer didn't provide feedback.

{
  "question": "How was your experience?",
  "answer": "It was great!",
  "sentiment": "positive"
}
  • question (string): the feedback prompt that was displayed
  • answer (string): the customer's response
  • sentiment (string): sentiment analysis result: "positive", "negative", or "neutral"

Why cancel session webhook counts differ from the dashboard

cancel_session_won fires once per session, the moment a customer accepts an offer. If a subscription has several cancel sessions in 24 hours, it can send several won events.

The dashboard save rate counts one definitive session per subscription per 24 hour window. The window starts when a cancel session begins. Cancel beats offer, and offer beats abandon. Abandoned or expired sessions count as saved without a won webhook if the customer doesn't cancel through the flow in that window, so a won tally alone can't reproduce the dashboard's Saved total. See Analytics & Insights for the save rate definition.

To reconcile offer saves, group events by processor_subscription_id and the same 24 hour window, keeping at most one won event per subscription per window. A cancellation through the flow in that window takes precedence and makes the result a loss. Include abandoned or expired sessions from your session data when comparing the overall Saved total.

The dashboard counts saves immediately, including in the all-time save rate. But a later session in the same window can overwrite a counted result. A won alert followed by a cancellation through the flow in that window turns the save into a loss, most often with offers you apply by hand. Notifications go out in real time on purpose; we don't hold them until the final result is known.

Bounce object

Present in: email_bounced, alongside the campaign object.

{
  "bounced_at": "2024-01-15T10:30:00Z",
  "can_activate": false,
  "description": "The recipient's email server permanently rejected the email.",
  "details": "550 5.1.1 No such user",
  "email": "invalid@example.com",
  "inactive": true,
  "type": "Hard Bounce"
}
  • bounced_at (ISO 8601): when the bounce occurred
  • can_activate (boolean): whether the email address can be reactivated
  • description (string): human-readable description of the bounce
  • details (string): SMTP response details from the mail server
  • email (string): the email address that bounced
  • inactive (boolean): whether the email address has been deactivated
  • type (string): bounce classification, e.g. "Hard Bounce", "Soft Bounce"

SQL query examples

These queries use PostgreSQL JSON operators (-> and ->>) to read fields from raw webhook payloads. If your warehouse tool flattens the JSON into columns (e.g. Fivetran's "Unpacked" format), reference those columns directly and skip the JSON operators.

To see your actual column names, run SELECT * FROM your_table LIMIT 5 after data starts flowing.

Monthly recovery rate and revenue

WITH monthly_campaigns AS (
  SELECT
    DATE_TRUNC('month',
      CAST(event->'campaign'->>'started_at' AS TIMESTAMP)
    ) AS month,
    COUNT(DISTINCT CASE
      WHEN event_type = 'campaign_started'
      THEN event->'campaign'->>'churn_buster_id'
    END) AS campaigns_started,
    COUNT(DISTINCT CASE
      WHEN event_type = 'campaign_won'
      THEN event->'campaign'->>'churn_buster_id'
    END) AS campaigns_won,
    SUM(CASE
      WHEN event_type = 'campaign_won'
      THEN CAST(event->'campaign'->'amount'->>'cents' AS NUMERIC) / 100
    END) AS revenue_recovered
  FROM churn_buster.events
  WHERE event_type IN ('campaign_started', 'campaign_won')
  GROUP BY 1
)

SELECT
  month,
  campaigns_started,
  campaigns_won,
  ROUND(
    100.0 * campaigns_won / NULLIF(campaigns_started, 0), 2
  ) AS recovery_rate_pct,
  ROUND(revenue_recovered, 2) AS total_recovered
FROM monthly_campaigns
ORDER BY month DESC;

Time to recovery

WITH campaign_lifecycle AS (
  SELECT
    event->'campaign'->>'churn_buster_id' AS campaign_id,
    event->'customer'->>'email' AS customer_email,
    MIN(CASE
      WHEN event_type = 'campaign_started'
      THEN CAST(event->'campaign'->>'started_at' AS TIMESTAMP)
    END) AS started_at,
    MAX(CASE
      WHEN event_type = 'campaign_won'
      THEN CAST(event->'campaign'->>'finished_at' AS TIMESTAMP)
    END) AS recovered_at,
    MAX(CASE
      WHEN event_type = 'campaign_won'
      THEN CAST(event->'campaign'->'amount'->>'cents' AS NUMERIC) / 100
    END) AS amount
  FROM churn_buster.events
  WHERE event_type IN ('campaign_started', 'campaign_won')
  GROUP BY 1, 2
  HAVING MAX(CASE
    WHEN event_type = 'campaign_won'
    THEN CAST(event->'campaign'->>'finished_at' AS TIMESTAMP)
  END) IS NOT NULL
)

SELECT
  DATE_TRUNC('week', started_at) AS week,
  COUNT(*) AS recoveries,
  ROUND(
    AVG(EXTRACT(EPOCH FROM (recovered_at - started_at)) / 3600), 1
  ) AS avg_hours_to_recovery,
  ROUND(
    PERCENTILE_CONT(0.5) WITHIN GROUP (
      ORDER BY EXTRACT(EPOCH FROM (recovered_at - started_at)) / 3600
    ), 1
  ) AS median_hours,
  ROUND(SUM(amount), 2) AS total_recovered
FROM campaign_lifecycle
GROUP BY 1
ORDER BY 1 DESC;

Cancel flow offer performance

This query counts accepted offers per session, not dashboard saves. See why cancel session webhook counts differ from the dashboard before comparing the totals.

SELECT
  event->'cancel_session'->'offer'->>'offer_type' AS offer_type,
  event->'cancel_session'->'offer'->>'offer_name' AS offer_name,
  COUNT(*) AS times_accepted,
  SUM(
    CAST(event->'cancel_session'->'amount'->>'cents' AS NUMERIC)
  ) / 100 AS total_mrr_retained,
  ROUND(
    AVG(CAST(event->'cancel_session'->'amount'->>'cents' AS NUMERIC)) / 100, 2
  ) AS avg_subscription_value
FROM churn_buster.events
WHERE event_type = 'cancel_session_won'
  AND event->'cancel_session'->'offer' IS NOT NULL
GROUP BY 1, 2
ORDER BY times_accepted DESC;

Top cancellation reasons

SELECT
  event->'cancel_session'->>'reason' AS cancellation_reason,
  COUNT(*) AS times_selected,
  ROUND(
    100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2
  ) AS percentage
FROM churn_buster.events
WHERE event_type = 'cancel_session_lost'
  AND event->'cancel_session'->>'reason' IS NOT NULL
GROUP BY 1
ORDER BY times_selected DESC
LIMIT 10;

Email bounce monitoring

SELECT
  DATE_TRUNC('week',
    CAST(event->'bounce'->>'bounced_at' AS TIMESTAMP)
  ) AS week,
  event->'bounce'->>'type' AS bounce_type,
  COUNT(*) AS bounce_count,
  COUNT(DISTINCT event->'customer'->>'email') AS unique_customers
FROM churn_buster.events
WHERE event_type = 'email_bounced'
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC;

dbt model examples

Staging model: flatten events

File: models/staging/stg_churn_buster__events.sql

{{
  config(
    materialized='incremental',
    unique_key='event_id'
  )
}}

WITH source AS (
  SELECT * FROM {{ source('churn_buster', 'events') }}
  {% if is_incremental() %}
  WHERE _fivetran_synced > (SELECT MAX(synced_at) FROM {{ this }})
  {% endif %}
),

parsed AS (
  SELECT
    {{ dbt_utils.generate_surrogate_key(
      ['account_uuid', 'event_type', 'event']
    ) }} AS event_id,
    account_uuid,
    event_type,
    _fivetran_synced AS synced_at,

    event->'customer'->>'churn_buster_id' AS customer_id,
    event->'customer'->>'email' AS customer_email,
    event->'customer'->>'processor' AS processor,
    event->'customer'->>'processor_id' AS processor_customer_id,

    CASE WHEN event_type IN (
      'campaign_started', 'campaign_won', 'campaign_lost', 'email_bounced'
    )
      THEN event->'campaign'->>'churn_buster_id'
    END AS campaign_id,

    CASE WHEN event_type IN (
      'campaign_started', 'campaign_won', 'campaign_lost', 'email_bounced'
    )
      THEN CAST(event->'campaign'->'amount'->>'cents' AS NUMERIC) / 100
    END AS campaign_amount,

    CASE WHEN event_type IN (
      'campaign_started', 'campaign_won', 'campaign_lost', 'email_bounced'
    )
      THEN CAST(event->'campaign'->>'started_at' AS TIMESTAMP)
    END AS campaign_started_at,

    CASE WHEN event_type IN ('campaign_won', 'campaign_lost')
      THEN CAST(event->'campaign'->>'finished_at' AS TIMESTAMP)
    END AS campaign_finished_at,

    CASE WHEN event_type IN ('cancel_session_won', 'cancel_session_lost')
      THEN event->'cancel_session'->>'churn_buster_id'
    END AS cancel_session_id,

    CASE WHEN event_type IN ('cancel_session_won', 'cancel_session_lost')
      THEN CAST(event->'cancel_session'->'amount'->>'cents' AS NUMERIC) / 100
    END AS cancel_session_amount,

    CASE WHEN event_type = 'cancel_session_won'
      THEN event->'cancel_session'->'offer'->>'offer_name'
    END AS accepted_offer_name,

    CASE WHEN event_type = 'cancel_session_won'
      THEN event->'cancel_session'->'offer'->>'offer_type'
    END AS accepted_offer_type,

    CASE WHEN event_type = 'cancel_session_lost'
      THEN event->'cancel_session'->>'reason'
    END AS cancellation_reason

  FROM source
)

SELECT * FROM parsed

Mart model: campaign performance

File: models/marts/fct_campaign_performance.sql

WITH campaigns AS (
  SELECT
    campaign_id,
    customer_id,
    customer_email,
    processor,
    campaign_amount,
    campaign_started_at,
    MAX(campaign_finished_at) AS campaign_finished_at,
    MAX(CASE WHEN event_type = 'campaign_won' THEN 1 ELSE 0 END) AS is_won,
    MAX(CASE WHEN event_type = 'campaign_lost' THEN 1 ELSE 0 END) AS is_lost
  FROM {{ ref('stg_churn_buster__events') }}
  WHERE event_type IN ('campaign_started', 'campaign_won', 'campaign_lost')
  GROUP BY 1, 2, 3, 4, 5, 6
),

final AS (
  SELECT
    campaign_id,
    customer_id,
    customer_email,
    campaign_amount,
    campaign_started_at,
    campaign_finished_at,
    CASE
      WHEN is_won = 1 THEN 'won'
      WHEN is_lost = 1 THEN 'lost'
      ELSE 'active'
    END AS campaign_status,
    CASE
      WHEN is_won = 1 THEN campaign_amount
      ELSE 0
    END AS revenue_recovered,
    EXTRACT(EPOCH FROM (campaign_finished_at - campaign_started_at)) / 3600
      AS hours_to_resolution
  FROM campaigns
)

SELECT * FROM final

Sample payloads

To see complete sample payloads for every event type, with your account's actual processor details, visit your webhook settings page:

Settings → Integrations → Webhooks → Webhook Sample Payloads

This shows the exact JSON structure for each event, including all nested objects and conditional fields.

Need help?

  • Setup guide: Data Warehouse Integration Guide

Works with your subscription platform, or connects through our API

  • Checkout Champ integration
  • Stripe integration
  • Shopify
  • Awtomic integration
  • Skio integration
  • Stay AI
  • Recharge integration
  • Seal Subscriptions integration
  • Ordergroove integration
  • Subbly integration
  • Smartrr integration
  • Recurly
  • Loop integration
  • Rivo integration
  • Braintree integration
  • Inveterate integration
Churn Buster

Established in 2013
Based in San Diego, CA

Questions?
Email support@churnbuster.io

Product
Dunning Cancel Flows Results Pricing Docs Sign In
Compare
Native dunning alternative Churnkey alternative Butter Payments alternative Chargebee Retention alternative Baremetrics Recover alternative Flycode alternative
Learn
Learn Passive Churn Failure Reasons Recovery Campaigns Recovery Rate Resources
Company
About Partners Security & Compliance Privacy Policy Terms of Service

© Churn Buster, LLC · All Rights Reserved