diff --git a/apps/playground/src/TestHarness.tsx b/apps/playground/src/TestHarness.tsx
index 43c3193..7386b74 100644
--- a/apps/playground/src/TestHarness.tsx
+++ b/apps/playground/src/TestHarness.tsx
@@ -59,7 +59,7 @@ const SCENARIOS: { id: Scenario; label: string; description: string }[] = [
{
id: 'all-offers',
label: 'All Built-in Offer Types',
- description: 'discount, pause, plan_change, trial_extension, contact, redirect — one reason each.',
+ description: 'discount, pause, plan_change, trial_extension, contact, redirect, rebate — one reason each.',
},
{
id: 'standalone-offer',
@@ -194,6 +194,19 @@ const allOffersSteps: Step[] = [
label: 'Learn more (→ redirect)',
offer: { type: 'redirect', url: 'https://example.com/docs', label: 'See docs' },
},
+ {
+ id: 'rebate',
+ label: 'Within the guarantee window (→ rebate)',
+ offer: {
+ type: 'rebate',
+ amountMinor: 500,
+ currency: 'USD',
+ amountPaidMinor: 10890,
+ netAfterRebateMinor: 10390,
+ paymentMethodBrand: 'visa',
+ paymentMethodLast4: '4242',
+ },
+ },
{ id: 'none', label: 'No reason (no offer)' },
],
},
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index c2d836a..d55cfd7 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -2,6 +2,20 @@
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Expect breaking changes in minor versions while we're pre-1.0.
+## 0.4.0 — 2026-05-29
+
+### Added
+
+- **Rebate offer type.** A `rebate` is a partial refund of the customer's most recent paid invoice while their subscription stays active — aimed at money-back-guarantee windows, where a customer who would cancel to get their money back can take a partial refund and stay instead.
+ - The `'rebate'` offer type joins the config union — the local shape plus the fields resolved in token mode (`amountMinor`, `currency`, `amountPaidMinor`, `netAfterRebateMinor`) — wired through the state machine, the config transform, and the offer-type map.
+ - `DefaultRebateOffer` renders the offer as an itemized invoice: the period charge, the rebate being credited, and what's still due, with the rebate line accented. Overridable via `components.RebateOffer`.
+ - `handleRebate` / `onRebate` callbacks, matching the other offer types. In connected mode the SDK runs the rebate server-side; defining `handleRebate` overrides that to run the refund through your own billing.
+ - The accepted rebate amount is recorded on the session.
+
+### Changed
+
+- Offer panels now share one surface. The discount and trial-extension panels use `colorSurfaceMuted` (the neutral callout surface) instead of `colorPrimarySoft` (the indigo tint), so every offer panel reads consistently and `colorPrimarySoft` is reserved for selected-state highlights as documented. Override the relevant `--ck-*` properties to restore the previous tint.
+
## 0.3.0 — 2026-05-19
### Added
diff --git a/packages/react/package.json b/packages/react/package.json
index 1973ed1..8b1a092 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@churnkey/react",
- "version": "0.3.0",
+ "version": "0.4.0",
"description": "Production-ready cancel flow for React. Drop-in component, headless hook, or full customization. Works standalone or with Churnkey for AI-powered retention.",
"license": "MIT",
"repository": {
diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts
index b6f84f5..0c9a912 100644
--- a/packages/react/src/components/index.ts
+++ b/packages/react/src/components/index.ts
@@ -10,6 +10,7 @@ export { DefaultContactOffer } from './steps/offer/default-contact-offer'
export { DefaultDiscountOffer } from './steps/offer/default-discount-offer'
export { DefaultPauseOffer } from './steps/offer/default-pause-offer'
export { DefaultPlanChangeOffer } from './steps/offer/default-plan-change-offer'
+export { DefaultRebateOffer } from './steps/offer/default-rebate-offer'
export { DefaultRedirectOffer } from './steps/offer/default-redirect-offer'
export { DefaultTrialExtensionOffer } from './steps/offer/default-trial-extension-offer'
// Structural
diff --git a/packages/react/src/components/steps/default-offer.tsx b/packages/react/src/components/steps/default-offer.tsx
index ac01ed2..75144ba 100644
--- a/packages/react/src/components/steps/default-offer.tsx
+++ b/packages/react/src/components/steps/default-offer.tsx
@@ -4,6 +4,7 @@ import { DefaultContactOffer } from './offer/default-contact-offer'
import { DefaultDiscountOffer } from './offer/default-discount-offer'
import { DefaultPauseOffer } from './offer/default-pause-offer'
import { DefaultPlanChangeOffer } from './offer/default-plan-change-offer'
+import { DefaultRebateOffer } from './offer/default-rebate-offer'
import { DefaultRedirectOffer } from './offer/default-redirect-offer'
import { DefaultTrialExtensionOffer } from './offer/default-trial-extension-offer'
@@ -43,6 +44,8 @@ function pickOfferComponent(
return components?.ContactOffer ?? DefaultContactOffer
case 'redirect':
return components?.RedirectOffer ?? DefaultRedirectOffer
+ case 'rebate':
+ return components?.RebateOffer ?? DefaultRebateOffer
default:
return null
}
diff --git a/packages/react/src/components/steps/offer/default-rebate-offer.tsx b/packages/react/src/components/steps/offer/default-rebate-offer.tsx
new file mode 100644
index 0000000..6b03118
--- /dev/null
+++ b/packages/react/src/components/steps/offer/default-rebate-offer.tsx
@@ -0,0 +1,70 @@
+import { formatPriceFromMinor } from '../../../core/format'
+import type { OfferDecision, OfferStepProps } from '../../../core/types'
+import { cn } from '../../../core/utils'
+import { RichText } from '../../rich-text'
+
+type RebateDecision = OfferDecision & {
+ amountMinor?: number
+ currency?: string
+ amountPaidMinor?: number
+ netAfterRebateMinor?: number
+}
+
+export function DefaultRebateOffer({
+ title,
+ description,
+ offer,
+ onAccept,
+ onDecline,
+ isProcessing,
+ classNames,
+}: OfferStepProps) {
+ const o = offer as RebateDecision
+ const headline = title ?? offer.copy.headline
+ const body = description ?? offer.copy.body
+ const currency = o.currency ?? 'usd'
+ const amount = o.amountMinor ?? 0
+
+ return (
+
+ {headline &&
{headline}
}
+ {body && }
+
+
+ {/* Itemized like an invoice: the period charge, the rebate we credit
+ (the accented line), and what's still due. Paid and net are
+ server-resolved in token mode, so each renders only when present. */}
+
+ {o.amountPaidMinor != null && (
+
+ Subscription · this period
+ {formatPriceFromMinor(o.amountPaidMinor, currency)}
+