Docs navigation
Offer types
An offer is a value proposition the SDK shows to keep the customer. There are six built-in types and a custom slot for anything beyond them. Attach an offer to a survey reason to route there on selection, or declare it as a standalone OfferStep to show one up front.
type OfferConfig =
| DiscountOffer
| PauseOffer
| PlanChangeOffer
| TrialExtensionOffer
| ContactOffer
| RedirectOffer
| CustomOfferConfigDiscountOffer
interface DiscountOffer {
type: 'discount'
couponId?: string // your coupon ID, passed to handleDiscount
percentOff?: number // shown in the UI
amountOff?: number // smallest currency unit, shown in the UI
currency?: string // ISO 4217, used with amountOff
durationInMonths?: number // shown in the UI ("for 3 months")
}The SDK never validates couponId against your billing provider. Keep the display fields (percentOff, durationInMonths) in sync with the coupon you reference.
If you don't have a coupon ID up front, omit it and build the coupon inside your handler from the display fields:
handleDiscount={async (offer) => {
if (offer.type !== 'discount') return
const coupon = await stripe.coupons.create({
percent_off: offer.percentOff,
duration: 'repeating',
duration_in_months: offer.durationInMonths,
})
await stripe.customers.update(customerId, { coupon: coupon.id })
}}PauseOffer
interface PauseOffer {
type: 'pause'
months: number // pause length
interval?: 'month' | 'week' // defaults to 'month'
}The default pause UI renders chips for 1 through months. Picking a chip and accepting fires handlePause with { months }.
PlanChangeOffer
interface PlanChangeOffer {
type: 'plan_change'
plans: PlanOption[]
}
interface PlanOption extends DirectPrice {
tagline?: string // e.g. "Most popular"
features?: string[] // bullet list shown on the plan card
msrp?: string // pre-formatted "before" price, struck-through
}Multiple plans render as picker rows by default. The default picker reads subscriptions[0].items[0].price.id to mark the customer's current plan with a "Current" badge and skip it during preselection. For a card-based layout with bigger feature bullets, override PlanChangeOffer. See the Plan change as stacked rows recipe.
TrialExtensionOffer
interface TrialExtensionOffer {
type: 'trial_extension'
days: number
}Used during trial periods. Accepting extends the trial; the new end date is computed server-side in connected mode, or by your handler in open source.
ContactOffer
interface ContactOffer {
type: 'contact'
url?: string // mailto:, tel:, or chat URL
label?: string // CTA label, e.g. "Email support"
}A non-billing offer for "Talk to us before you go" patterns. Contact accepts flow through the catch-all onAccept listener — there's no handleContact because the SDK doesn't know what "applying" a contact offer means. Read offer.type === 'contact' inside onAccept and open the URL, route to a chat widget, or fire whatever side effect makes sense.
RedirectOffer
interface RedirectOffer {
type: 'redirect'
url: string
label: string
}For sending the customer to a different page — onboarding restart, a help article, a competitor comparison. The accept action is a regular anchor; no handle<Type> runs.
CustomOfferConfig
interface CustomOfferConfig {
type: string // any string that isn't built-in
data?: Record<string, unknown>
}Register a component on customComponents keyed by type. Accepts go through onAccept; there's no built-in handle<Type> for custom offers. See Custom step + offer types.
AcceptedOffer
The shape your handle<Type>, on<Type>, and onAccept callbacks receive:
type AcceptedOffer = OfferConfig & {
reasonId?: string // present when a survey reason routed here
result?: Record<string, unknown> // custom-offer payload
}| Field | Meaning |
|---|---|
reasonId | The survey reason ID that routed to this offer. Absent for offers declared as a standalone OfferStep. |
result | For custom offers, whatever your component passed to onAccept(result). Built-in offer types don't populate this field. |
AcceptedOffer is a union over every offer type. Narrow on offer.type to access type-specific fields:
handleDiscount={async (offer) => {
if (offer.type !== 'discount') return
// offer.couponId, offer.percentOff, offer.durationInMonths
}}
onAccept={(offer) => {
if (offer.type === 'discount') {
// offer.couponId, offer.percentOff
} else if (offer.type === 'change-seats') {
// (offer.result as { seats: number }).seats
}
}}Offer copy
Every offer the SDK renders is an OfferDecision: the offer config plus computed copy. In connected mode the copy is server-driven (with Adaptive Offers picking and optimizing it on the Intelligence tier). In open source it's derived from the offer's display fields.
interface OfferCopy {
headline: string
body: string
cta: string // e.g. "Claim 20% off"
declineCta: string // e.g. "No thanks"
}
type OfferDecision = OfferConfig & {
copy: OfferCopy
decisionId?: string // server-assigned ID in connected mode
}Offer components receive the full OfferDecision. Read offer.copy for the rendered text, or render your own.
Next steps
- Callbacks —
handle<Type>andon<Type>signatures. - Custom step + offer types — adding offer types not in this list.
- Replacing components — overriding per-offer-type rendering.