Webhooks
Webhooks enable Churnkey to push real-time event notifications to your application. Once you’ve registered an endpoint URL, Churnkey will send a secure HTTPS POST request to that URL every time one of your customers completes a Churnkey session. The JSON payload will include session information like their survey response, any freeform feedback they left, and the outcome of the session e.g. subscription pause, cancellation, discount, or if they abandoned the cancellation flow.
Webhooks can be managed from your Churnkey account
Use Cases
Clients use Churnkey webhooks to:
- Update customer profile information in CRMs such as Hubspot or Intercom
- Example: marking every customer who cited “technical difficulties” in their survey response for a tutorial email campaign
- Create a customized Slack thread where customer success teams can discuss next steps for customer retention
- Send “secret plan” offers to customers who already declined offers on Churnkey Please note that for business logic related directly to customer billing and subscriptions, we recommend you use Stripe webhooks. This accounts for the possibility that subscriptions may be modified outside of the Churnkey flow.
Webhook Event Data
As soon as a webhook is enabled, Churnkey will start to stream new event notifications to that URL. These will be HTTPS POST requests with a JSON payload of the below format:
type SegmentFilter = {
attribute: string;
operand: string;
value: string[];
type: string;
}
type Segment = {
name: string;
filter: SegmentFilter[];
}
type ABTest = {
name: string;
id: string;
}
type DiscountConfig = {
couponId: string;
customDuration: string;
}
type PauseConfig = {
maxPauseLength: number;
pauseInterval: string;
datePicker: boolean;
}
type TrialExtensionConfig = {
trialExtensionDays: number;
}
type PlanChangeConfig = {
options: string[];
}
type RedirectConfig = {
redirectUrl: string;
redirectLabel: string;
}
type PresentedOffer = {
guid: string;
offerType: 'DISCOUNT' | 'PAUSE' | 'PLAN_CHANGE' | 'CONTACT' | 'TRIAL_EXTENSION' | 'REDIRECT';
discountConfig?: DiscountConfig;
pauseConfig?: PauseConfig;
trialExtensionConfig?: TrialExtensionConfig;
planChangeConfig?: PlanChangeConfig;
redirectConfig?: RedirectConfig;
}
type AcceptedOffer = {
guid: string;
offerType: 'discount' | 'pause' | 'plan change' | 'contact' | 'trial extension' | 'redirect';
pauseEndDate?: Date;
pauseInterval?: 'MONTH' | 'WEEK';
pauseDuration?: number;
pauseEndDate?: Date; // if specific date chosen
newPlanId?: string;
newPlanPrice?: number;
redirectUrl?: string;
couponId?: string;
couponType?: 'PERCENT' | 'AMOUNT';
couponAmount?: number;
couponDuration?: number;
trialExtensionDays?: number;
}
type Session = {
result: 'abort' | 'cancel' | 'pause' | 'discount' | 'plan_change' | 'contact' | 'trial_extension' | 'redirect';
presentedOffers: PresentedOffer[];
acceptedOffer?: AcceptedOffer;
mode: 'TEST' | 'LIVE';
segment?: Segment;
abTest?: ABTest;
subscriptionId: string;
feedback?: string; // freeform feedback
surveyResponse?: string;
followupQuestion?: string; // from survey selection
followupResponse?: string; // the freeform follow-up answer (see below)
followupChoiceId?: string; // guid of the follow-up option the customer picked
followupChoiceValue?: string; // label of the follow-up option the customer picked
}
type SessionData = {
session: Session;
// customer from payment provider with expanded subscription(s)
customer: StripeCustomer | ChargebeeCustomer | ...
}
export type SessionWebhookPayload = {
event: 'session' | 'dunning';
data: SessionData;
}
Example Session Webhook Payload
{
"event": "session",
"data": {
"customer": <UP TO DATE CUSTOMER>,
"session": {
"result": "pause", // pause, discount, cancel, abort
"feedback": "Freeform feedback from customer",
"surveyResponse": "Too Expensive",
"mode": "LIVE", // billing mode, LIVE or TEST,
"usedClickToCancel": true, // If customer clicked on the Cancel Now button
"customAttributes": {
"favoriteAnimal": "penguin" // All customAttributes you sent during the session
},
"followupQuestion": "From survey question",
// Freeform text the customer typed. For a Structured follow-up this instead
// repeats followupChoiceValue. See "Follow-up Answers" below.
"followupResponse": "Customer response to survey follow-up question",
// The follow-up option the customer picked, when the question offered options.
"followupChoiceId": "a1b2c3d4-...",
"followupChoiceValue": "Label of the option the customer picked",
"acceptedOffer": {
// details below
"offerType": "PAUSE",
"pauseDuration": 2,
},
// Segment information will be sent if the customer belonged to a segment defined in Churnkey
"segment": {
"name": "My annual customers",
"filter": [{
"attribute": "BILLING_INTERVAL",
"operand": "INCLUDES",
"value": ["YEAR"]
}]
},
"abTest": {
"name": "A/B test for discount percentage",
"id": "Unique ID for AB Test"
}
}
}
}
If a customer accepts an offer through Churnkey, the data.session object will include an acceptedOffer property with information about the offer they accepted. Depending on the offer type, this object will look different.
Follow-up Answers
A survey choice can ask a follow-up question. Depending on how the question is configured, the customer answers it by typing, by picking an option, or by doing both — and the answer arrives in different fields in each case.
The configuration names below are the ones shown in the cancel flow builder, on the survey choice's follow-up question.
| Follow-up configuration | followupResponse | followupChoiceValue / followupChoiceId |
|---|---|---|
| Freeform — the customer types an answer | the typed text | absent |
| Structured — the customer picks one option | the option's label | the option's label / guid |
| Freeform + Structured — the customer picks an option, then types | the typed text | the option's label / guid |
The third row is the one to design for: both fields are populated and they hold different answers. followupResponse holds only the typed text, so the option the customer picked appears nowhere but followupChoiceValue.
To read every follow-up answer reliably, read both fields. Treat followupChoiceValue as the picked option and followupResponse as the typed text. Note that in the Structured row the two are equal, so skip the typed text when it just repeats the label:
const picked = session.followupChoiceValue
// In the Structured row followupResponse repeats the label; only keep real typed text.
const typed = session.followupResponse === picked ? undefined : session.followupResponse
const answer = [picked, typed].filter(Boolean).join(' — ')
// Freeform → "It was missing the reports I needed"
// Structured → "A competitor"
// Freeform + Structured → "A competitor — It was missing the reports I needed"
If you already read only followupResponse, you will keep receiving Freeform answers, and Structured answers arrive there too — but you will silently miss the picked option whenever a question is configured as Freeform + Structured.
Prefer followupChoiceId over followupChoiceValue when you need a stable identifier: the guid survives a wording change to the option, the label does not.
surveyResponse always holds the top-level cancellation reason, whichever follow-up configuration is used.
Webhook Signature Verification
For enhanced security and data integrity, every webhook sent by our service is accompanied by a signature. This allows you to verify that the webhook was genuinely sent from our servers.
How We Sign Outgoing Webhooks
- Payload Stringification: We first convert the webhook payload to a string by using JSON stringify.
- HMAC Computation: Using the SHA256 algorithm, an HMAC (Hash-based Message Authentication Code) is computed on the stringified payload. The secret key for this HMAC computation is the
webhookSecretassociated with your organization. - Header Attachment: The resulting hash from the HMAC computation is then attached to the outgoing webhook as a header named
ck-signature.
How to Verify the Webhook Signature
To verify the webhook signature on your end:
- Retrieve the Secret: Get your
webhookSecretfrom our service. This is your org secret key which can be found on Churnkey | Settings | Account. - Capture the Payload and Signature: When you receive a webhook, capture the payload (body) and the value of the
ck-signatureheader. - Compute the HMAC: Use the SHA256 algorithm to compute an HMAC on the payload, using your
webhookSecretas the secret key. - Match the Signatures: Compare the computed HMAC value (from the previous step) with the
ck-signaturevalue you captured. If the two match, then the webhook is verified and was genuinely sent by our service.
Node.js Code Example
const crypto = require('crypto');
// find your account secret under Cancel Flow API Key: https://app.churnkey.co/settings/account
function verifyWebhookSignature(payload, receivedSignature, secret) {
// Compute the HMAC
const computedHmac = crypto.createHmac('sha256', secret).update(JSON.stringify(payload)).digest('hex');
// Compare the computed HMAC with the received signature
return computedHmac === receivedSignature;
}
// Usage:
const payload = req.body; // assuming you are using a body-parser middleware in Express.js
const receivedSignature = req.headers['ck-signature'];
const secret = 'YOUR_WEBHOOK_SECRET'; // Retrieve this from our service or your configuration
if (verifyWebhookSignature(payload, receivedSignature, secret)) {
console.log('Webhook signature is valid!');
} else {
console.log('Webhook signature is invalid or has been tampered with.');
}
For more code examples, please check the server side HMAC signing on Installing Churnkey
Pause offer accepted
{
"offerType": "PAUSE",
"pauseDuration": 2 // pause duration in months
}
Discount offer accepted
{
"offerType": "DISCOUNT",
"couponId": "my_coupon_id", // coupon Id in Stripe/Chargebee, discount ID in Braintree
"couponType": "PERCENT", // PERCENT or AMOUNT
"couponAmount": 30,
"couponDuration": 2 // coupon duration in months - null if forever coupon
}
Dunning Webhook Data
If you have webhooks enabled and use dunning, Churnkey will stream email event notifications to your webhook URL. There are multiple events for each email based on the recipient’s actions. Potential action values are DELIVERY, BOUNCE, OPEN, CLICK.
{
"event": "dunning",
"data": {
"email": {
"emailTo": "[email protected]",
"subject": "Need help with your subscription?",
"from": "[email protected]",
"action": "DELIVERY", // DELIVERY, BOUNCE, OPEN, or CLICK
"emailCount": 2, // This emails order in the campaign
"emailsRemaining": 3 // Emails remaining in campaign
},
"customer": "cus_XXXXXXXXXXXX",
"invoice": "inv_XXXXXXXXXXXX"
}
}