Webhooks
Receive real-time notifications when key events happen in Churn Solution. This guide explains how the events are generated, the structure of the payload we send you, and how to securely verify every request.
Overview
When a relevant event happens in Churn Solution — for example, a
customer completes a cancellation flow — we send an HTTP
POST request to the endpoint URL you have registered with
us. The body is JSON, and a cryptographic signature is included in a
header so you can verify the payload genuinely came from Churn Solution.
| HTTP method | POST |
| Content-Type | application/json |
| Signature header | Churnsolution-Signature |
| Field naming | All JSON keys are snake_case |
| Timestamps | UTC Unix timestamps in seconds (not milliseconds) |
You will be provided with a signing secret for each registered endpoint. Keep it secret — it is the only thing that lets you prove a payload is authentic.
Endpoint requirements
Your endpoint must:
- Be reachable over HTTPS at a stable, publicly accessible URL.
- Accept a JSON
POSTbody. -
Respond with a 2xx status code once the event has
been received and stored. Any
4xx/5xxresponse is treated as a failed delivery. - Respond quickly. Acknowledge receipt first, then do heavy processing asynchronously.
Request format
Every webhook is delivered with this top-level envelope:
{
"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 identifier of this webhook event. Use it for idempotency / de-duplication. |
app_id |
string | Your application identifier on the originating billing gateway. |
data |
object |
The event payload. Always contains an object
discriminator.
|
data.object |
string |
The type of object contained in data (e.g.
"session").
|
created_at |
integer | UTC Unix timestamp (seconds) when the event was created. Used in the signature. |
attempt_number |
integer | Which delivery attempt this is (starts at 1). |
Request headers
| Header | Description |
|---|---|
Churnsolution-Signature |
Hex-encoded HMAC-SHA256 signature of the payload. See Verifying signatures. |
Content-Type |
application/json |
Event types
session — Cancellation-flow session
Sent when a customer completes a cancellation-flow session. The
data.object is "session" and the
data.session object is populated. This is currently the
primary business event.
The Session object
data.session describes a customer's journey through a
cancellation flow and its outcome.
{
"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 | Unique session identifier. |
status |
string (enum) | Final session status. See values below. |
is_saved |
boolean |
true if the customer was retained (saved);
false if they churned.
|
created_at |
integer | UTC Unix seconds when the session was created. |
customer |
object | The customer. See Customer. |
subscription |
object | The subscription in question. See Subscription. |
plan |
object | The plan / price on the subscription. See Plan. |
cancellation_flow |
object | The flow (or A/B variant) the customer went through. See Cancellation flow. |
uncancel_coupon |
object |
Coupon applied if the customer un-cancelled; null
when not applicable. See Coupon.
|
steps |
array | Ordered steps the customer went through. 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 | Customer identifier on the billing gateway. |
name |
string | Customer name. |
email |
string | Customer email. |
created_at |
integer | UTC Unix seconds when the customer was created. |
Subscription
| Field | Type | Description |
|---|---|---|
id |
string | Subscription identifier on the billing gateway. |
status |
string (enum) | Subscription status. See values below. |
start_date |
integer | UTC Unix seconds — subscription start. |
current_period_start |
integer | UTC Unix seconds — current billing period start. |
current_period_end |
integer | UTC Unix seconds — current billing period end. |
trial_start |
integer |
UTC Unix seconds — trial start (null if no trial).
|
trial_end |
integer |
UTC Unix seconds — trial end (null if no trial).
|
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 on the billing gateway. |
recurring |
object |
Billing cadence:
{ "interval": "month", "interval_count": 1 }.
|
recurring.interval |
string |
Billing interval, e.g. day, week,
month, year.
|
recurring.interval_count |
integer | Number of intervals between billings. |
currency |
string | ISO currency code, e.g. usd. |
unit_amount |
integer | Amount in the smallest currency unit (e.g. cents). |
unit_amount_decimal |
number | Amount in major units, e.g. 20.0. |
quantity |
integer | Quantity of the plan. |
plan_summary |
string | Human-readable summary of the plan. |
Cancellation flow
| Field | Type | Description |
|---|---|---|
id |
integer | Cancellation flow identifier. |
name |
string | Name of the flow shown to the customer (A/B variant or audience). |
created_at |
integer | UTC Unix seconds — flow creation. |
updated_at |
integer | UTC Unix seconds — last flow update. |
Session steps
session.steps is an array describing each step the customer
encountered, in display order. Each step has a type, and
the fields relevant to that type are populated.
{
"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 |
string (enum) |
One of FORM, SURVEY,
OFFER.
|
order |
integer | 1-based position of this step within the session. |
contains_offer |
boolean | true if an offer was presented at this step. |
survey_answer |
string | (SURVEY) The survey option the customer selected. |
followup_question |
string | (SURVEY) Follow-up question shown for the selected option, if any. |
followup_question_answer |
string | (SURVEY) The customer's free-text follow-up answer. |
form_answer |
string | (FORM) The customer's free-text form answer. |
offer |
object |
The offer presented at this step. Present when
contains_offer is true. See
Offer.
|
type are
null. For example, a FORM step has
form_answer populated and survey_answer
null.
Offer
Exactly one of coupon, pause, or
trial_extension is populated, indicating the kind of offer
made.
| Field | Type | Description |
|---|---|---|
accepted |
boolean | true if the customer accepted the offer. |
coupon |
object |
A discount coupon. null if not a coupon offer. See
Coupon.
|
pause |
object |
A subscription pause: { "number_of_months": 2 }.
|
trial_extension |
object |
A trial extension: { "number_of_days": 14 }.
|
Coupon
| Field | Type | Description |
|---|---|---|
name |
string | Coupon name. |
code |
string | Coupon code. |
currency |
string | ISO currency code (for fixed-amount coupons). |
amount_off |
integer |
Fixed discount in the smallest currency unit (e.g. cents).
null for percentage coupons.
|
amount_off_decimal |
number |
Fixed discount in 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 |
Number of months the coupon repeats (when duration
is repeating).
|
number_of_billing_cycles |
integer | Number of billing cycles the coupon applies to, if defined. |
currency_symbol |
string | Display symbol for currency, e.g. $. |
summary |
string | Human-readable summary of the discount. |
Security
Verifying signatures
Every request includes a Churnsolution-Signature header. It
is a hex-encoded HMAC-SHA256 of the payload, keyed with
your endpoint's signing secret.
How the signature is computed
signed_payload = "<created_at>;<raw_request_body>"
signature = lowercase_hex( HMAC_SHA256( signing_secret, signed_payload ) )
-
<created_at>is the integer value of the top-levelcreated_atfield in the body. -
<raw_request_body>is the exact, unmodified raw bytes of the request body. Do not re-serialize the parsed JSON — whitespace and key ordering must match exactly. - The two parts are joined by a single semicolon (
;).
Verification steps
- Read the raw request body as received (before any JSON parsing or reformatting).
- Read the
created_atvalue from the body. -
Compute
HMAC_SHA256(signing_secret, created_at + ";" + raw_body)and hex-encode it. -
Compare your result to the
Churnsolution-Signatureheader using a constant-time comparison. - Reject the request (
401) if they do not match.
Node.js (Express)
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
}
);
Python (Flask)
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
-
Success is any non-error HTTP response
(
2xx/3xx). A4xx/5xx, a connection error, or a timeout is recorded as a failed delivery. -
The
attempt_numberfield indicates the delivery attempt for this event. -
Idempotency: the same logical event may be delivered
more than once. De-duplicate using the top-level
idso you process each event exactly once. -
Ordering: do not assume webhooks arrive in the order
events occurred. Use
created_atto order events if needed.
Best practices
- Always verify the signature before acting on a payload.
-
Respond fast (
2xx) and process asynchronously. - Be tolerant of new fields. We may add fields over time; ignore unknown fields rather than failing.
-
Treat fields as nullable. Optional fields may be
nullor absent depending on the scenario. - Keep your signing secret secret. Store it in a secrets manager or environment variable, never in client-side code.
- Use HTTPS so payloads are encrypted in transit.
Quick reference
| HTTP method | POST |
| Body format | JSON, snake_case keys |
| Signature header | Churnsolution-Signature |
| Signature algorithm | HMAC-SHA256, hex-encoded (lowercase) |
| Signed payload | "<created_at>;<raw_body>" |
| Signing key | Per-endpoint signing secret (provided to you) |
| Expected response | 2xx |
| Timestamps | UTC Unix seconds |
| Idempotency key | Top-level id |