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
  | CustomOfferConfig

DiscountOffer

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
}
FieldMeaning
reasonIdThe survey reason ID that routed to this offer. Absent for offers declared as a standalone OfferStep.
resultFor 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