---
title: Custom offer types
description: Define your own offer types in code and let your team configure them in the builder.
---

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.

::alert{type="info"}
Custom offers render through the [`@churnkey/react` SDK](https://github.com/churnkey/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.

```bash
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](#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](#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`:

```json
{ "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`.

```tsx
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.

```tsx
<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.

::alert{type="info"}
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.
::
