API Integration / Webhook Data Reference
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 is retained through a cancel flow offer |
cancel_session_lost |
Cancel Flow | The customer completes cancellation |
email_bounced |
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) includecampaignandcustomer. - Cancel flow events (
cancel_session_won,cancel_session_lost) includecancel_sessionandcustomer. -
email_bouncedincludescampaign,bounce, andcustomer.
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 incampaign_wonandcampaign_lostevents -
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 fordiscountandskip_and_discountoffer types -
swap_variant_id(string): only present forswapoffer 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"
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
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