Webhooks
Churn Solution sends real-time notifications when key events occur in your account. This guide covers how events are generated, the structure of each payload, and how to verify that every request came from Churn Solution.
On this page
- Overview
- Endpoint requirements
- Request format
- Request headers
- Event types
- session — Cancellation-flow session
- The Session object
- status values
- Customer
- Subscription
- Subscription status values
- Plan
- Cancellation flow
- Session steps
- Offer
- Coupon
- Security
- Verifying signatures
- How the signature is computed
- Verification steps
- Delivery & retries
- Best practices
- Quick reference
Overview
When a relevant event occurs — such as a customer completing a cancellation flow — Churn Solution sends an HTTP POST request to your registered endpoint. The body is JSON, and a signature in the request headers lets you confirm the sender.
| Property | Value |
|---|---|
| HTTP method | POST |
| Content-Type | application/json |
| Signature header | Churnsolution-Signature |
| Field naming | snake_case keys |
| Timestamps | UTC Unix seconds (not milliseconds) |
Each registered endpoint receives its own signing secret. Keep it private — it is what proves a payload is authentic.
Endpoint requirements
- Serve over HTTPS
At a stable, public URL.
- Accept a JSON POST body
The request is sent as
application/json. - Return a 2xx status
Once the event is stored. Any
4xxor5xxcounts as a failed delivery. - Respond quickly
Acknowledge receipt first and process heavy work asynchronously.
Always verify the signature before trusting any data in the payload.
Request format
Every webhook uses the same top-level envelope: an event id, your app_id, a data object whose object field names the payload type, created_at and attempt_number.
{
"id": 90217,
"app_id": "your-app-identifier",
"data": {
"object": "session",
"session": { /* see The Session object below */ }
},
"created_at": 1718971200,
"attempt_number": 1
}
| Field | Type | Description |
|---|---|---|
id |
integer | Unique event ID. Use it for de-duplication. |
app_id |
string | Your app identifier on the billing gateway. |
data |
object | The event payload, with an object discriminator. |
data.object |
string | Object type, e.g. "session". |
created_at |
integer | UTC Unix seconds. Used in the signature. |
attempt_number |
integer | Delivery attempt count, starting at 1. |
Request headers
| Header | Value |
|---|---|
Churnsolution-Signature |
Hex-encoded HMAC-SHA256 signature |
Content-Type |
application/json |
Event types
Churn Solution currently sends one event type.
session — Cancellation-flow session
Sent when a customer completes a cancellation-flow session. data.object is "session", and data.session holds the Session object described below. This is the primary business event.
The Session object
The data.session object describes a customer's path through a cancellation flow and how it ended: the final status, whether the customer was saved, and nested customer, subscription, plan, flow, coupon and step details.
{
"id": 12345,
"status": "CANCELLED",
"is_saved": false,
"created_at": 1718971200,
"customer": {
"id": "cus_abc123",
"name": "Jane Doe",
"email": "[email protected]",
"created_at": 1700000000
},
"subscription": {
"id": "sub_abc123",
"status": "ACTIVE",
"start_date": 1700000000,
"current_period_start": 1717200000,
"current_period_end": 1719792000,
"trial_start": null,
"trial_end": null
},
"plan": {
"id": "price_abc123",
"recurring": { "interval": "month", "interval_count": 1 },
"currency": "usd",
"unit_amount": 2000,
"unit_amount_decimal": 20.0,
"quantity": 1,
"plan_summary": "$20.00 / month"
},
"cancellation_flow": {
"id": 55,
"name": "Default Cancel Flow",
"created_at": 1700000000,
"updated_at": 1716000000
},
"uncancel_coupon": null,
"steps": [ /* see Session steps below */ ]
}
| Field | Type | Description |
|---|---|---|
id |
integer | Session identifier. |
status |
enum string | Final session status. See status values. |
is_saved |
boolean | true if the customer was retained, false if they churned. |
created_at |
integer | UTC Unix seconds. |
customer |
object | See Customer. |
subscription |
object | See Subscription. |
plan |
object | See Plan. |
cancellation_flow |
object | See Cancellation flow. |
uncancel_coupon |
object | See Coupon. null when not applicable. |
steps |
array | See Session steps. |
status values
ABANDONED SEND_TO_PAGE CONTACT TRIAL_EXTENSION PAUSE SWITCH_PLAN COUPON CANCELLED PENDING_CANCEL UNCANCELLED UNPAUSED REACTIVATED SWITCH_PLAN_WITH_COUPON
Customer
| Field | Type | Description |
|---|---|---|
id |
string | Gateway customer identifier. |
name |
string | Customer name. |
email |
string | Customer email. |
created_at |
integer | UTC Unix seconds. |
Subscription
| Field | Type | Description |
|---|---|---|
id |
string | Gateway subscription identifier. |
status |
enum string | Subscription status. See values below. |
start_date |
integer | Subscription start. |
current_period_start |
integer | Current billing period start. |
current_period_end |
integer | Current billing period end. |
trial_start |
integer | null if no trial. |
trial_end |
integer | null if no trial. |
Subscription status values
ACTIVE TRIALING PAST_DUE PAUSED INCOMPLETE INCOMPLETE_EXPIRED CANCELED UNPAID EXPIRED PENDING HALTED ON_HOLD
Plan
| Field | Type | Description |
|---|---|---|
id |
string | Plan / price identifier. |
recurring |
object | Billing cadence, e.g. { "interval": "month", "interval_count": 1 }. |
recurring.interval |
string | day, week, month or year. |
recurring.interval_count |
integer | Number of intervals between billings. |
currency |
string | ISO code, e.g. usd. |
unit_amount |
integer | Amount in the smallest currency unit. |
unit_amount_decimal |
number | Amount in major units. |
quantity |
integer | Plan quantity. |
plan_summary |
string | Human-readable summary. |
Cancellation flow
| Field | Type | Description |
|---|---|---|
id |
integer | Flow identifier. |
name |
string | Flow name the customer went through, such as an A/B variant or audience. |
created_at |
integer | UTC Unix seconds. |
updated_at |
integer | UTC Unix seconds. |
Session steps
session.steps is an ordered array. Each step has a type; fields that do not apply to a step's type are null.
{
"id": 9001,
"type": "SURVEY",
"order": 1,
"contains_offer": true,
"survey_answer": "Too expensive",
"followup_question": "What price would work for you?",
"followup_question_answer": "Around $10",
"form_answer": null,
"offer": {
"accepted": true,
"coupon": {
"name": "Win-back 50%",
"code": "STAY50",
"currency": "usd",
"amount_off": null,
"amount_off_decimal": null,
"percent_off": 50,
"duration": "repeating",
"duration_in_months": 3,
"number_of_billing_cycles": 3,
"currency_symbol": "$",
"summary": "50% for 3 billing cycles"
},
"pause": null,
"trial_extension": null
}
}
| Field | Type | Description |
|---|---|---|
id |
integer | Step identifier. |
type |
enum | FORM, SURVEY or OFFER. |
order |
integer | 1-based position in the flow. |
contains_offer |
boolean | Whether an offer was shown in this step. |
survey_answer |
string | SURVEY only. The selected option. |
followup_question |
string | SURVEY only. The follow-up question, if any. |
followup_question_answer |
string | SURVEY only. Free-text answer. |
form_answer |
string | FORM only. Free-text answer. |
offer |
object | Present when contains_offer is true. See Offer. |
Offer
Exactly one of coupon, pause or trial_extension is populated.
| Field | Type | Description |
|---|---|---|
accepted |
boolean | Whether the customer accepted the offer. |
coupon |
object / null | See Coupon. |
pause |
object | e.g. { "number_of_months": 2 } |
trial_extension |
object | e.g. { "number_of_days": 14 } |
Coupon
| Field | Type | Description |
|---|---|---|
name |
string | Coupon name. |
code |
string | Coupon code. |
currency |
string | ISO code, for fixed-amount coupons. |
amount_off |
integer | Smallest currency unit. null for percentage coupons. |
amount_off_decimal |
number | Major units. null for percentage coupons. |
percent_off |
integer | Percentage discount. null for fixed-amount coupons. |
duration |
string | once, repeating or forever. |
duration_in_months |
integer | Months, when duration is repeating. |
number_of_billing_cycles |
integer | Billing cycles, if defined. |
currency_symbol |
string | Display symbol, e.g. $. |
summary |
string | Human-readable discount summary. |
Security
Verifying signatures
Each request carries a hex-encoded HMAC-SHA256 signature in the Churnsolution-Signature header, keyed with your endpoint's signing secret. Verify it with a constant-time comparison and reject mismatches with a 401.
How the signature is computed
signed_payload = "<created_at>;<raw_request_body>"
signature = lowercase_hex( HMAC_SHA256( signing_secret, signed_payload ) )
Use the exact bytes you received as the raw body. Do not re-serialize the parsed JSON — key order or whitespace changes will break the signature.
Verification steps
- Read the raw request body
Before any parsing.
- Extract
created_atFrom the body.
- Compute the HMAC-SHA256
Over
created_at, a semicolon, and the raw body, then hex-encode the result. - Compare
Against the
Churnsolution-Signatureheader using a constant-time comparison. - Reject mismatches
Respond with
401.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SIGNING_SECRET = process.env.CHURNSOLUTION_SIGNING_SECRET;
// Capture the raw body — required for signature verification.
app.post(
'/webhooks/churnsolution',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body; // Buffer
const signature = req.get('Churnsolution-Signature');
const createdAt = JSON.parse(rawBody.toString()).created_at;
const signedPayload = `${createdAt};${rawBody.toString()}`;
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(signedPayload)
.digest('hex');
const valid =
signature &&
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!valid) return res.status(401).send('Invalid signature');
const event = JSON.parse(rawBody.toString());
res.status(200).send('ok'); // acknowledge immediately
handleEvent(event); // process asynchronously
}
);
import hmac, hashlib, json, os
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["CHURNSOLUTION_SIGNING_SECRET"]
@app.post("/webhooks/churnsolution")
def churnsolution_webhook():
raw_body = request.get_data() # raw bytes
signature = request.headers.get("Churnsolution-Signature", "")
created_at = json.loads(raw_body)["created_at"]
signed_payload = f"{created_at};{raw_body.decode()}".encode()
expected = hmac.new(
SIGNING_SECRET.encode(), signed_payload, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = json.loads(raw_body)
return "ok", 200
Delivery & retries
- Any
2xxor3xxresponse counts as a successful delivery.4xx/5xxresponses, connection failures and timeouts count as failures. attempt_numbertells you which delivery attempt this is.- The same event may be delivered more than once. De-duplicate using the top-level
id. - Events may arrive out of order. Use
created_atto sort them if ordering matters.
Best practices
- Verify the signature before acting on any payload.
- Respond quickly; process asynchronously.
- Ignore unknown fields — new ones may be added over time.
- Treat optional fields as possibly
nullor absent. - Store the signing secret in a secrets manager or environment variable, never in client-side code.
- Use HTTPS.
Quick reference
| Property | Value |
|---|---|
| HTTP method | POST |
| Body format | JSON, snake_case keys |
| Signature header | Churnsolution-Signature |
| Algorithm | HMAC-SHA256, lowercase hex |
| Signed payload | <created_at>;<raw_body> |
| Signing key | Per-endpoint signing secret |
| Expected response | 2xx |
| Timestamps | UTC Unix seconds |
| Idempotency key | Top-level id |