Skip to main content
This example shows the shape of a headless integration. Your application owns all UI. Fanfare provides the public state and state-gated actions.
import initFanfare from "@fanfare-io/fanfare-sdk-core";
import type {
  JourneyView,
  SequenceView,
} from "@fanfare-io/fanfare-sdk-core/experiences";

const sdk = await initFanfare({
  organizationId: "org_123",
  publishableKey: "pk_live_123",
});

const journey = sdk.journeys.get("exp_123");

journey.view$.listen((view) => {
  render(view);
});

function render(view: JourneyView) {
  if (view.journeyStage === "ready") {
    mountStartButton(() => view.start());
    return;
  }

  if (view.journeyStage === "routing") {
    mountLoading();
    return;
  }

  if (view.journeyStage === "gated") {
    mountGate({
      gates: view.gates,
      onAccessCode: (accessCode) => view.reroute({ accessCode }),
      onRetry: () => view.retry(),
    });
    return;
  }

  if (view.offers.length > 0) {
    // Routed upgrade offers are always gated and informational. Submitting an
    // access code is single-shot: only "advanced" commits the code and moves the
    // journey into the offered sequence; "unchanged" leaves current participation
    // in place, while "gated" pushes the journey into a gated stage where the gate
    // UI must take over.
    mountUpgradeOffers({
      offers: view.offers,
      onAccessCode: async (accessCode) => {
        const result = await view.submitAccessCode(accessCode);
        if (result.outcome === "advanced") {
          mountUpgraded(result.sequenceId);
        } else if (result.outcome === "gated") {
          mountUpgradeBlocked();
        } else {
          mountUpgradeRejected();
        }
      },
    });
  }

  renderSequence(view.sequence);
}

function renderSequence(sequence: SequenceView) {
  switch (sequence.phase) {
    case "unavailable":
      mountUnavailable();
      return;

    case "scheduled":
      mountUpcoming({
        startsAt: sequence.startsAt,
        // WaitlistAttachmentView | undefined: { status, join(), leave() }
        waitlist: sequence.waitlist,
      });
      return;

    case "enterable":
      mountEnterable(sequence);
      return;

    case "participating":
      if (sequence.mechanism === "appointment") {
        // Appointments never expose leave(); manage the booked slot instead.
        mountParticipation({
          type: sequence.mechanism,
          display$: sequence.display$, // live slot, location, check-in state
          onCancel: (reason) => sequence.cancel(reason),
          onReschedule: (newSlotId, newLocationId) =>
            sequence.reschedule(newSlotId, newLocationId),
        });
        return;
      }
      mountParticipation({
        type: sequence.mechanism,
        display$: sequence.display$, // live participating data (read here, not state$)
        onLeave: "leave" in sequence ? () => sequence.leave() : undefined,
      });
      return;

    case "settling":
      mountSettling(sequence);
      return;

    case "granted":
      // claim() returns the AdmissionGrant synchronously and starts checkout.
      mountCheckout(sequence.claim().token);
      return;

    case "ended":
      mountEnded({ reason: sequence.outcome.type });
  }
}
  • Own the product page, checkout routing, analytics policy, and visual design.
  • Render every public state you can receive.
  • Call only the actions exposed by the current JourneyView.
  • Keep grants and session values out of third-party logs.

What to avoid

  • Do not infer private routing or enforcement decisions from the state.
  • Do not assume a state transition order that is not represented by JourneyView.
  • Do not show buttons for actions that are not present in the current state.