Custom offer types
Churnkey's built-in offers cover the common save actions: discounts, pauses, plan changes, trial extensions, and rebates. A custom offer type lets you add your own. Your engineers define the type once. Your retention team then configures it, places it in flows, and A/B tests it in the builder, the same way they work with built-in offers.
A custom offer type is three pieces working together:
- A registered type. A name, a stable key, and the fields your team can configure. You register it once via API.
- A React component. Your app renders the offer with your own component, using the configuration your team entered.
- An accept callback. When a customer accepts, Churnkey calls your code with the offer's configuration. You run the action against your own billing system.
Custom offers render through the @churnkey/react SDK, version 0.6.1 or later. The embed and hosted cancel flows skip them, so use custom types on flows served to your React integration.
Register the type
Register the type's manifest once. Authenticate with a Bearer token for a dashboard user with cancel-flow write access.
curl -X POST https://api.churnkey.co/v1/api/custom-offer-types \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"key": "annual_term_extension",
"name": "Annual term extension",
"description": "Extend the member'\''s annual term by N days.",
"fields": [
{ "key": "days", "type": "number", "label": "Days to extend", "required": true, "default": 30, "min": 1, "max": 365 }
]
}'
| Parameter | Description |
|---|---|
key | Stable identifier for the type. See Choosing a key. |
name | Shown in the builder's offer picker. |
description | Shown to your team in the builder. Optional. |
fields | The configuration form. See Defining fields. |
PUT /custom-offer-types/:id updates a type. DELETE /custom-offer-types/:id retires it. Deletes are soft: flows that reference a retired type degrade gracefully instead of breaking.
Choosing a key
The key connects the pieces. The builder stores it on every configured offer, the SDK matches it against your customComponents, and your accept callback switches on it.
Keys contain letters, numbers, hyphens, and underscores, and are unique within your account. Built-in type names are reserved: discount, pause, plan_change, trial_extension, contact, redirect, rebate, custom, survey, offer, feedback, confirm, success.
Defining fields
Each field renders as one input in the builder. The values your team enters become the offer's configuration, delivered to your component as offer.data.
| Field type | Renders as | Type-specific properties |
|---|---|---|
text | Text input | |
number | Number input | min, max |
currency | Amount input | min, max. Stored in the currency's minor unit (cents). |
boolean | Toggle | |
select | Dropdown | options: [{ value, label }] |
multiSelect | Multi-select | options: [{ value, label }] |
Every field takes key, type, and label. Fields can also take help (shown under the input), required (marks the field), and default (pre-filled when your team adds the offer).
To show a field only when another field has a specific value, set showIf:
{ "key": "customDays", "type": "number", "label": "Days", "showIf": { "field": "mode", "eq": "custom" } }
Configure the offer in the builder
- Open a cancel flow and add or edit an offer step.
- Pick the type from the offer picker. It appears under its
name, after the built-in offers. - Fill in the fields.
- Write the step's header and description. These become the offer's headline and body in your component.
- Publish the flow.
Render the offer
Register a component for the key in customComponents. The component receives the offer, with configuration under offer.data and copy under offer.copy, plus onAccept, onDecline, and isProcessing.
import { CancelFlow, RichText } from '@churnkey/react'
import type { CustomOfferProps } from '@churnkey/react/core'
function AnnualTermExtension({ offer, onAccept, onDecline, isProcessing }: CustomOfferProps) {
const days = (offer.data?.days as number) ?? 30
return (
<div className="ck-step ck-step-offer">
<h2 className="ck-step-title">{offer.copy.headline}</h2>
<RichText html={offer.copy.body} className="ck-step-description" />
<div className="ck-offer-card">
<button
type="button"
className="ck-button ck-button-primary"
onClick={() => onAccept({ days })}
disabled={isProcessing}
>
{isProcessing ? 'Extending…' : `Add ${days} free days`}
</button>
<button type="button" className="ck-button-link" onClick={onDecline}>
No thanks, cancel
</button>
</div>
</div>
)
}
Render offer.copy.body with the RichText component. The builder's description editor produces HTML, and a plain text node renders the markup escaped.
Handle the accept
Custom offers don't have a named handler like handleDiscount. When a customer accepts, the onAccept callback fires with the offer's key, its configuration, and whatever your component passed to onAccept(result). Run your action there. If the callback throws, the flow shows an error and the offer is not marked accepted.
<CancelFlow
appId={appId}
customer={customer}
session={session}
customComponents={{ annual_term_extension: AnnualTermExtension }}
onAccept={async (offer, customer) => {
if (offer.type === 'annual_term_extension') {
const { days } = offer.data as { days: number }
await yourBackend.extendTerm(customer.id, days)
}
}}
/>
What Churnkey records
An accepted custom offer counts as a save, the same as a built-in offer. The session records the key as customOfferType and your component's result as customOfferResult. Both arrive in your session webhook. Analytics report each custom type separately, grouped by key.
The builder's live preview shows a placeholder card for custom offers. The real rendering is your component, which exists only in your app, so verify the customer-facing experience in your own integration.