Custom offer types

Define your own offer types in code and let your team configure them in the builder.
View Markdown

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:

  1. A registered type. A name, a stable key, and the fields your team can configure. You register it once via API.
  2. A React component. Your app renders the offer with your own component, using the configuration your team entered.
  3. 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 }
    ]
  }'
ParameterDescription
keyStable identifier for the type. See Choosing a key.
nameShown in the builder's offer picker.
descriptionShown to your team in the builder. Optional.
fieldsThe 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 typeRenders asType-specific properties
textText input
numberNumber inputmin, max
currencyAmount inputmin, max. Stored in the currency's minor unit (cents).
booleanToggle
selectDropdownoptions: [{ value, label }]
multiSelectMulti-selectoptions: [{ 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

  1. Open a cancel flow and add or edit an offer step.
  2. Pick the type from the offer picker. It appears under its name, after the built-in offers.
  3. Fill in the fields.
  4. Write the step's header and description. These become the offer's headline and body in your component.
  5. 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.