Docs navigation

Offer types

An offer is a value proposition the SDK shows to keep the customer. There are seven 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
  | RebateOffer
  | 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.

RebateOffer

interface RebateOffer {
  type:                 'rebate'
  amountMinor:          number       // the rebate amount (pre-tax); card is refunded this plus tax
  currency:             string       // ISO 4217
  amountPaidMinor?:     number       // gross paid on the invoice — the "you paid" row
  netAfterRebateMinor?: number       // amountPaid − full refund (rebate + tax) — the "your net" row
  paymentMethodBrand?:  string       // e.g. "Visa"
  paymentMethodLast4?:  string
}

A rebate refunds part of an already-paid invoice while the subscription continues — for money-back-guarantee windows where a customer would otherwise cancel for a refund. In connected mode the amount and the display fields are resolved server-side from the customer's most recent paid invoice (via a Stripe credit note), so amountPaidMinor, netAfterRebateMinor, and the card fields arrive populated. A customer outside the guarantee window, or whose invoice was already rebated, never sees the step — the server omits the offer rather than render an ineligible state.

Connected mode is Stripe-only. To run the refund through your own system instead, define handleRebate and skip the server action — see Callbacks. In open source, set amountMinor and currency yourself; the optional display fields render the breakdown rows when present.

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