Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.fanfare.io/llms.txt

Use this file to discover all available pages before exploring further.

Consumer Linking Guide

Learn how to upgrade anonymous (guest) consumers to identified accounts while preserving their participation history.

Overview

Consumer linking connects an anonymous session to a verified identity. This is essential when a guest consumer wants to receive notifications, track their history, or access their account from another device. What you’ll learn:
  • Linking guest sessions to email/phone
  • Preserving participation history during linking
  • Handling the linking flow in your UI
  • Server-side external identity linking
Complexity: Intermediate Time to complete: 30 minutes

Prerequisites

  • Fanfare SDK installed and configured
  • Understanding of guest and identified authentication
  • Anonymous Consumers guide completed
Before Linking (Guest)After Linking (Identified)
No notificationsEmail/SMS notifications
Single device onlyCross-device access
Session expiresPersistent identity
No order historyFull history tracking
Anonymous in dashboardIdentifiable in dashboard
Show a linking prompt to guest users:
import { useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useState } from "react";

function LinkAccountPrompt() {
  const { isAuthenticated, isGuest, session } = useFanfareAuth();
  const [dismissed, setDismissed] = useState(false);

  // Only show to authenticated guests
  if (!isAuthenticated || !isGuest || dismissed) {
    return null;
  }

  return (
    <div className="link-prompt">
      <div className="link-prompt-content">
        <h3>Want to track your progress?</h3>
        <p>Add your email to receive notifications and access your account from any device.</p>

        <div className="link-prompt-actions">
          <LinkAccountButton />
          <button onClick={() => setDismissed(true)} className="dismiss-btn">
            Not now
          </button>
        </div>
      </div>
    </div>
  );
}

Step 2: Implement OTP Linking

Link a guest session to an email:
import { useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useState } from "react";

function LinkAccountButton() {
  const { isGuest, requestOtp, verifyOtp } = useFanfareAuth();
  const [showModal, setShowModal] = useState(false);
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [step, setStep] = useState<"email" | "code">("email");
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  if (!isGuest) return null;

  const handleRequestOtp = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);
    setIsLoading(true);

    try {
      await requestOtp({ email });
      setStep("code");
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to send code");
    } finally {
      setIsLoading(false);
    }
  };

  const handleVerifyOtp = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);
    setIsLoading(true);

    try {
      // Verifying OTP automatically links the guest to this email
      await verifyOtp({ email, code });
      setShowModal(false);
      // Session is now identified!
    } catch (err) {
      setError(err instanceof Error ? err.message : "Invalid code");
    } finally {
      setIsLoading(false);
    }
  };

  if (!showModal) {
    return (
      <button onClick={() => setShowModal(true)} className="link-btn">
        Add Email
      </button>
    );
  }

  return (
    <div className="modal-overlay">
      <div className="modal">
        <button onClick={() => setShowModal(false)} className="close-btn">
          &times;
        </button>

        {step === "email" ? (
          <form onSubmit={handleRequestOtp}>
            <h3>Add Your Email</h3>
            <p>We'll send you a verification code.</p>

            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="[email protected]"
              required
              disabled={isLoading}
            />

            {error && <p className="error">{error}</p>}

            <button type="submit" disabled={isLoading}>
              {isLoading ? "Sending..." : "Send Code"}
            </button>
          </form>
        ) : (
          <form onSubmit={handleVerifyOtp}>
            <h3>Enter Code</h3>
            <p>We sent a code to {email}</p>

            <input
              type="text"
              value={code}
              onChange={(e) => setCode(e.target.value)}
              placeholder="123456"
              maxLength={6}
              required
              disabled={isLoading}
            />

            {error && <p className="error">{error}</p>}

            <button type="submit" disabled={isLoading}>
              {isLoading ? "Verifying..." : "Verify & Link"}
            </button>

            <button type="button" onClick={() => setStep("email")} className="link-button">
              Change email
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

Step 3: Preserve Participation During Linking

The Fanfare backend automatically preserves all participation data when linking:
Guest Session (guestId: abc123)
├── Queue participation (queue_xyz)
├── Draw entry (draw_456)
└── Waitlist signup (waitlist_789)

        ↓ After OTP verification ↓

Identified Session (email: [email protected])
├── Queue participation (queue_xyz) ✓ Preserved
├── Draw entry (draw_456) ✓ Preserved
└── Waitlist signup (waitlist_789) ✓ Preserved

What Gets Preserved

  • Queue positions and admission tokens
  • Draw entries and results
  • Auction bids
  • Waitlist signups
  • Order history (if any)
  • Analytics and tracking data

Step 4: Phone Number Linking

Link to a phone number instead of email:
function LinkPhoneForm() {
  const { requestOtp, verifyOtp } = useFanfareAuth();
  const [phone, setPhone] = useState("");
  const [countryCode, setCountryCode] = useState("US");
  const [code, setCode] = useState("");
  const [step, setStep] = useState<"phone" | "code">("phone");

  const handleLink = async () => {
    if (step === "phone") {
      await requestOtp({ phone, defaultCountry: countryCode });
      setStep("code");
    } else {
      await verifyOtp({ phone, code, defaultCountry: countryCode });
      // Success - account is now linked to phone
    }
  };

  // ... UI similar to email linking
}

Step 5: External Identity Linking

Link consumers via your own authentication system (server-side):

Backend Implementation

// Your backend API endpoint
// POST /api/link-fanfare-identity

import { NextResponse } from "next/server";

export async function POST(request: Request) {
  // 1. Verify user is authenticated in your system
  const session = await getYourSession(request);
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 2. Create exchange code via Fanfare API (server-to-server)
  const response = await fetch("https://api.fanfare.io/auth/external/authorize", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Organization-Id": process.env.FANFARE_ORG_ID!,
      "X-Secret-Key": process.env.FANFARE_SECRET_KEY!, // Secret key!
    },
    body: JSON.stringify({
      provider: "your-platform", // Your platform identifier
      issuer: "https://your-domain.com",
      subject: session.user.id, // Your user's unique ID
      claims: {
        email: session.user.email,
        name: session.user.name,
        // Add any other relevant claims
      },
    }),
  });

  if (!response.ok) {
    return NextResponse.json({ error: "Failed to create exchange code" }, { status: 500 });
  }

  const { exchangeCode, expiresAt } = await response.json();

  // 3. Return exchange code to frontend
  return NextResponse.json({ exchangeCode, expiresAt });
}

Frontend Implementation

import { useFanfare, useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useEffect } from "react";

interface User {
  id: string;
  email: string;
  name: string;
}

function useFanfareLink(user: User | null) {
  const fanfare = useFanfare();
  const { isAuthenticated, isGuest } = useFanfareAuth();

  useEffect(() => {
    async function linkAccount() {
      // Only link if user is logged in to your platform
      // and Fanfare session is guest (not already linked)
      if (!user || !isGuest) return;

      try {
        // 1. Get exchange code from your backend
        const res = await fetch("/api/link-fanfare-identity", {
          method: "POST",
          credentials: "include", // Include auth cookies
        });

        if (!res.ok) {
          console.error("Failed to get exchange code");
          return;
        }

        const { exchangeCode } = await res.json();

        // 2. Exchange for Fanfare authenticated session
        await fanfare.auth.exchangeExternal({ exchangeCode });

        console.log("Account linked successfully!");
      } catch (error) {
        console.error("Failed to link account:", error);
      }
    }

    linkAccount();
  }, [user, isGuest, fanfare]);

  return { isLinked: isAuthenticated && !isGuest };
}

Step 6: Automatic Linking on Login

Automatically link when users log into your platform:
function AutoLinkWrapper({ children }: { children: React.ReactNode }) {
  const { user } = useYourAuth(); // Your auth hook
  const { isLinked } = useFanfareLink(user);

  return (
    <>
      {children}
      {user && !isLinked && <LinkingIndicator />}
    </>
  );
}

function LinkingIndicator() {
  return <div className="linking-toast">Syncing your account...</div>;
}

Step 7: Handle Linking Conflicts

What happens if the email/phone is already linked to another consumer?
async function handleLinkWithConflictResolution(email: string, code: string) {
  try {
    await verifyOtp({ email, code });
    // Success - linked to existing or new consumer
  } catch (error) {
    if (error instanceof Error) {
      if (error.message.includes("already_linked")) {
        // This email is already linked to another consumer
        // Options:
        // 1. Merge accounts (if you support this)
        // 2. Ask user to use different email
        // 3. Sign out and sign in with that identity
        showConflictResolutionUI(email);
      } else {
        showError(error.message);
      }
    }
  }
}

Conflict Resolution UI

function ConflictResolutionModal({
  email,
  onContinueAsNew,
  onSignInExisting,
}: {
  email: string;
  onContinueAsNew: () => void;
  onSignInExisting: () => void;
}) {
  return (
    <div className="conflict-modal">
      <h3>Email Already in Use</h3>
      <p>
        The email <strong>{email}</strong> is already associated with another account.
      </p>

      <div className="options">
        <button onClick={onSignInExisting} className="primary">
          Sign in to existing account
        </button>
        <p className="or">or</p>
        <button onClick={onContinueAsNew} className="secondary">
          Use a different email
        </button>
      </div>
    </div>
  );
}

Step 8: Post-Linking Actions

After successful linking, consider these actions:
import { useFanfare, useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useEffect, useRef } from "react";

function PostLinkActions() {
  const { isAuthenticated, isGuest, session } = useFanfareAuth();
  const fanfare = useFanfare();
  const wasGuest = useRef(true);

  useEffect(() => {
    // Detect transition from guest to identified
    if (wasGuest.current && isAuthenticated && !isGuest) {
      onAccountLinked();
      wasGuest.current = false;
    }
  }, [isAuthenticated, isGuest]);

  async function onAccountLinked() {
    // 1. Show success message
    showToast("Account linked successfully!");

    // 2. Refresh participations to get updated data
    const me = await fanfare.experiences.getMe();
    console.log("Active experiences:", me.active);

    // 3. Track analytics
    trackEvent("account_linked", {
      consumerId: session?.consumerId,
      email: session?.email,
    });

    // 4. Prompt for notification preferences
    showNotificationPreferences();
  }

  return null;
}

Best Practices

1. Time the Linking Prompt Well

Don’t interrupt the user during critical moments:
function SmartLinkPrompt() {
  const { status } = useExperienceJourney();

  // Don't show during active participation
  if (["participating", "admitted"].includes(status)) {
    return null;
  }

  // Good times to show:
  // - After successful queue entry
  // - When viewing confirmation
  // - During waitlist signup
  return <LinkAccountPrompt />;
}

2. Explain the Benefits

<div className="link-benefits">
  <h4>Why add your email?</h4>
  <ul>
    <li>Get notified when it's your turn</li>
    <li>Access from any device</li>
    <li>Track your order history</li>
    <li>Never lose your spot</li>
  </ul>
</div>

3. Make it Optional

Never force linking for basic participation:
// Good - Optional linking
{
  isGuest && <OptionalLinkPrompt />;
}
{
  /* User can still participate without linking */
}

// Avoid - Forced linking
{
  isGuest && <BlockingLinkModal />;
} // Don't do this

4. Handle Errors Gracefully

const handleLinkError = (error: Error) => {
  // Don't disrupt the user's experience
  console.error("Link failed:", error);

  // Show non-blocking error
  showToast("Couldn't link account. You can try again later.", "warning");

  // User can continue as guest
};

Troubleshooting

Participation Not Preserved

  • Verify the same browser/device is used
  • Check that session wasn’t cleared
  • Ensure linking happens before participation ends

Exchange Code Expired

  • Exchange codes expire after 60 seconds
  • Generate new code if expired
  • Check network latency

Email Already Linked

  • Provide options to sign in or use different email
  • Don’t silently fail

What’s Next