Skip to main content

Identified Consumers Guide

Learn how to authenticate consumers with email or phone number using OTP (One-Time Password) verification.

Overview

Identified authentication creates a verified consumer record tied to an email address or phone number. This enables features like notifications, order history, and cross-device session continuity. What you’ll learn:
  • Implementing email OTP authentication
  • Implementing phone OTP authentication
  • Managing authenticated sessions
  • Handling authentication flows in your UI
Complexity: Intermediate Time to complete: 30 minutes

Prerequisites

  • Fanfare SDK installed and configured
  • Understanding of async authentication flows
  • Email/SMS delivery configured in Fanfare dashboard

When to Use Identified Authentication

Use identified authentication when you need:
  • Notifications: Email/SMS updates about queue position, draw results
  • Cross-device access: Same consumer identity across devices
  • Order history: Link purchases to a persistent identity
  • Audience targeting: Target consumers based on email domain, etc.

Authentication Flow

┌─────────────────────────────────────────────────────────┐
│                   User Starts Auth                       │
└─────────────────────────────────────────────────────────┘


              ┌───────────────────────┐
              │   Enter Email/Phone   │
              └───────────────────────┘


              ┌───────────────────────┐
              │   Request OTP Code    │──────────────┐
              │   POST /auth/otp/     │              │
              │        request        │              │
              └───────────────────────┘              │
                           │                        │
                           ▼                        │
              ┌───────────────────────┐              │
              │   User Receives OTP   │◄─────────────┘
              │   (Email or SMS)      │
              └───────────────────────┘


              ┌───────────────────────┐
              │   Enter OTP Code      │
              └───────────────────────┘


              ┌───────────────────────┐
              │   Verify OTP Code     │
              │   POST /auth/otp/     │
              │        verify         │
              └───────────────────────┘


              ┌───────────────────────┐
              │   Session Created     │
              │   (Authenticated)     │
              └───────────────────────┘

Step 1: Email OTP Authentication

Request OTP

import { useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useState } from "react";

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

  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 {
      await verifyOtp({ email, code });
      // Success! User is now authenticated
    } catch (err) {
      setError(err instanceof Error ? err.message : "Invalid code");
    } finally {
      setIsLoading(false);
    }
  };

  if (isAuthenticated) {
    return (
      <div className="auth-success">
        <p>Signed in as: {session?.email}</p>
      </div>
    );
  }

  if (step === "email") {
    return (
      <form onSubmit={handleRequestOtp} className="auth-form">
        <h2>Sign In</h2>
        <p>Enter your email to receive 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>
    );
  }

  return (
    <form onSubmit={handleVerifyOtp} className="auth-form">
      <h2>Enter Verification Code</h2>
      <p>We sent a code to {email}</p>

      <input
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value)}
        placeholder="123456"
        maxLength={6}
        pattern="[0-9]*"
        inputMode="numeric"
        required
        disabled={isLoading}
        autoFocus
      />

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

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

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

Step 2: Phone OTP Authentication

import { useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useState } from "react";

function PhoneAuthForm() {
  const { requestOtp, verifyOtp, isAuthenticated, session } = useFanfareAuth();
  const [phone, setPhone] = useState("");
  const [code, setCode] = useState("");
  const [step, setStep] = useState<"phone" | "code">("phone");
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [countryCode, setCountryCode] = useState("US");

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

    try {
      // Include country code for proper E.164 formatting
      await requestOtp({ phone, defaultCountry: countryCode });
      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 {
      await verifyOtp({ phone, code, defaultCountry: countryCode });
      // Success! User is now authenticated
    } catch (err) {
      setError(err instanceof Error ? err.message : "Invalid code");
    } finally {
      setIsLoading(false);
    }
  };

  if (isAuthenticated) {
    return (
      <div className="auth-success">
        <p>Signed in with: {session?.phone}</p>
      </div>
    );
  }

  if (step === "phone") {
    return (
      <form onSubmit={handleRequestOtp} className="auth-form">
        <h2>Sign In with Phone</h2>
        <p>Enter your phone number to receive a verification code.</p>

        <div className="phone-input">
          <select value={countryCode} onChange={(e) => setCountryCode(e.target.value)} disabled={isLoading}>
            <option value="US">+1 (US)</option>
            <option value="GB">+44 (UK)</option>
            <option value="CA">+1 (CA)</option>
            <option value="AU">+61 (AU)</option>
            {/* Add more countries as needed */}
          </select>

          <input
            type="tel"
            value={phone}
            onChange={(e) => setPhone(e.target.value)}
            placeholder="(555) 123-4567"
            required
            disabled={isLoading}
          />
        </div>

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

        <button type="submit" disabled={isLoading}>
          {isLoading ? "Sending..." : "Send Code"}
        </button>
      </form>
    );
  }

  return (
    <form onSubmit={handleVerifyOtp} className="auth-form">
      <h2>Enter Verification Code</h2>
      <p>We sent a code to your phone</p>

      <input
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value)}
        placeholder="123456"
        maxLength={6}
        pattern="[0-9]*"
        inputMode="numeric"
        required
        disabled={isLoading}
        autoFocus
      />

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

      <button type="submit" disabled={isLoading}>
        {isLoading ? "Verifying..." : "Verify"}
      </button>
    </form>
  );
}

Step 3: Combined Auth Form

Offer both email and phone authentication:
import { useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useState } from "react";

type AuthMethod = "email" | "phone";
type AuthStep = "choose" | "input" | "verify";

function CombinedAuthForm() {
  const { requestOtp, verifyOtp, isAuthenticated, session } = useFanfareAuth();
  const [method, setMethod] = useState<AuthMethod>("email");
  const [step, setStep] = useState<AuthStep>("choose");
  const [identifier, setIdentifier] = useState("");
  const [code, setCode] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const handleRequestOtp = async () => {
    setError(null);
    setIsLoading(true);

    try {
      if (method === "email") {
        await requestOtp({ email: identifier });
      } else {
        await requestOtp({ phone: identifier, defaultCountry: "US" });
      }
      setStep("verify");
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to send code");
    } finally {
      setIsLoading(false);
    }
  };

  const handleVerifyOtp = async () => {
    setError(null);
    setIsLoading(true);

    try {
      if (method === "email") {
        await verifyOtp({ email: identifier, code });
      } else {
        await verifyOtp({ phone: identifier, code, defaultCountry: "US" });
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : "Invalid code");
    } finally {
      setIsLoading(false);
    }
  };

  if (isAuthenticated) {
    return (
      <div className="auth-success">
        <h3>Welcome!</h3>
        <p>Signed in as: {session?.email || session?.phone}</p>
      </div>
    );
  }

  if (step === "choose") {
    return (
      <div className="auth-choose">
        <h2>Sign In</h2>
        <p>Choose how you'd like to verify your identity</p>

        <button
          onClick={() => {
            setMethod("email");
            setStep("input");
          }}
        >
          Continue with Email
        </button>

        <button
          onClick={() => {
            setMethod("phone");
            setStep("input");
          }}
        >
          Continue with Phone
        </button>
      </div>
    );
  }

  if (step === "input") {
    return (
      <div className="auth-input">
        <h2>Enter your {method}</h2>

        <input
          type={method === "email" ? "email" : "tel"}
          value={identifier}
          onChange={(e) => setIdentifier(e.target.value)}
          placeholder={method === "email" ? "[email protected]" : "(555) 123-4567"}
          disabled={isLoading}
        />

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

        <button onClick={handleRequestOtp} disabled={isLoading || !identifier}>
          {isLoading ? "Sending..." : "Send Verification Code"}
        </button>

        <button onClick={() => setStep("choose")} className="link-button">
          Use different method
        </button>
      </div>
    );
  }

  return (
    <div className="auth-verify">
      <h2>Enter Code</h2>
      <p>We sent a 6-digit code to your {method}</p>

      <input
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
        placeholder="123456"
        maxLength={6}
        inputMode="numeric"
        disabled={isLoading}
        autoFocus
      />

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

      <button onClick={handleVerifyOtp} disabled={isLoading || code.length !== 6}>
        {isLoading ? "Verifying..." : "Verify"}
      </button>

      <button onClick={handleRequestOtp} disabled={isLoading} className="link-button">
        Resend code
      </button>
    </div>
  );
}

Step 4: Core SDK Implementation

Using the core SDK directly:
import Fanfare from "@waitify-io/fanfare-sdk-core";

const sdk = await Fanfare.init({
  organizationId: "org_xxx",
  publishableKey: "pk_live_xxx",
});

// Email authentication
async function authenticateWithEmail(email: string) {
  // Step 1: Request OTP
  await sdk.auth.requestOtp({ email });
  console.log("OTP sent to:", email);

  // Step 2: Wait for user to enter code...
  // In a real app, show UI for code entry
}

async function verifyEmailOtp(email: string, code: string) {
  const session = await sdk.auth.verifyOtp({ email, code });
  console.log("Authenticated:", session.consumerId);
  console.log("Email:", session.email);
  return session;
}

// Phone authentication
async function authenticateWithPhone(phone: string, countryCode: string) {
  await sdk.auth.requestOtp({ phone, defaultCountry: countryCode });
  console.log("OTP sent to:", phone);
}

async function verifyPhoneOtp(phone: string, code: string, countryCode: string) {
  const session = await sdk.auth.verifyOtp({ phone, code, defaultCountry: countryCode });
  console.log("Authenticated:", session.consumerId);
  console.log("Phone:", session.phone);
  return session;
}

Step 5: Session Management

Check Authentication Status

function AuthStatus() {
  const { isAuthenticated, isGuest, session } = useFanfareAuth();

  return (
    <div className="auth-status">
      {isAuthenticated ? (
        <>
          <p>Status: {isGuest ? "Guest" : "Identified"}</p>
          <p>Consumer: {session?.consumerId}</p>
          {session?.email && <p>Email: {session.email}</p>}
          {session?.phone && <p>Phone: {session.phone}</p>}
          <p>Expires: {session?.expiresAt}</p>
        </>
      ) : (
        <p>Not authenticated</p>
      )}
    </div>
  );
}

Logout

function LogoutButton() {
  const { isAuthenticated, logout } = useFanfareAuth();

  if (!isAuthenticated) return null;

  const handleLogout = async () => {
    try {
      await logout();
      // Optionally redirect or show message
    } catch (error) {
      console.error("Logout failed:", error);
    }
  };

  return <button onClick={handleLogout}>Sign Out</button>;
}

Step 6: Handling OTP Errors

Common OTP error cases:
async function handleVerifyOtp(email: string, code: string) {
  try {
    await verifyOtp({ email, code });
  } catch (error) {
    if (error instanceof Error) {
      // Handle specific error types
      if (error.message.includes("otp_expired")) {
        // Code has expired (usually after 10 minutes)
        showError("Code expired. Please request a new one.");
        setStep("input"); // Go back to request new code
      } else if (error.message.includes("otp_invalid")) {
        // Wrong code entered
        showError("Invalid code. Please check and try again.");
        setCode(""); // Clear the input
      } else if (error.message.includes("rate_limit")) {
        // Too many attempts
        showError("Too many attempts. Please wait a few minutes.");
      } else {
        // Generic error
        showError("Verification failed. Please try again.");
      }
    }
  }
}

Step 7: OTP Input Component

Create a user-friendly OTP input:
interface OtpInputProps {
  length?: number;
  value: string;
  onChange: (value: string) => void;
  disabled?: boolean;
}

function OtpInput({ length = 6, value, onChange, disabled }: OtpInputProps) {
  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);

  const handleChange = (index: number, digit: string) => {
    if (!/^\d*$/.test(digit)) return;

    const newValue = value.split("");
    newValue[index] = digit;
    onChange(newValue.join(""));

    // Auto-advance to next input
    if (digit && index < length - 1) {
      inputRefs.current[index + 1]?.focus();
    }
  };

  const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
    if (e.key === "Backspace" && !value[index] && index > 0) {
      inputRefs.current[index - 1]?.focus();
    }
  };

  const handlePaste = (e: React.ClipboardEvent) => {
    e.preventDefault();
    const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
    onChange(pastedData);
  };

  return (
    <div className="otp-input" onPaste={handlePaste}>
      {Array.from({ length }).map((_, index) => (
        <input
          key={index}
          ref={(el) => {
            inputRefs.current[index] = el;
          }}
          type="text"
          inputMode="numeric"
          maxLength={1}
          value={value[index] || ""}
          onChange={(e) => handleChange(index, e.target.value)}
          onKeyDown={(e) => handleKeyDown(index, e)}
          disabled={disabled}
          className="otp-digit"
          autoFocus={index === 0}
        />
      ))}
    </div>
  );
}

Authenticated Session Data

interface AuthenticatedSession {
  type: "authenticated";
  consumerId: string; // Unique consumer identifier
  email?: string; // If authenticated with email
  phone?: string; // If authenticated with phone (E.164 format)
  expiresAt: string; // ISO 8601 timestamp
  deviceFingerprint?: string;
}

Best Practices

1. Validate Input Before Sending

function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

function isValidPhone(phone: string): boolean {
  // Basic validation - actual validation happens server-side
  return /^\+?[\d\s-()]+$/.test(phone) && phone.replace(/\D/g, "").length >= 10;
}

2. Show Loading States

<button disabled={isLoading}>
  {isLoading ? (
    <>
      <Spinner /> Sending...
    </>
  ) : (
    "Send Code"
  )}
</button>

3. Implement Resend Cooldown

function ResendButton({ onResend }: { onResend: () => Promise<void> }) {
  const [cooldown, setCooldown] = useState(0);

  useEffect(() => {
    if (cooldown > 0) {
      const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
      return () => clearTimeout(timer);
    }
  }, [cooldown]);

  const handleResend = async () => {
    await onResend();
    setCooldown(60); // 60 second cooldown
  };

  return (
    <button onClick={handleResend} disabled={cooldown > 0}>
      {cooldown > 0 ? `Resend in ${cooldown}s` : "Resend code"}
    </button>
  );
}

4. Clear Sensitive Data

// Clear code input after failed attempts
const handleVerifyError = () => {
  setCode("");
  codeInputRef.current?.focus();
};

Troubleshooting

OTP Not Received

  • Check spam/junk folders for email
  • Verify phone number includes country code
  • Check SMS delivery status in Fanfare dashboard
  • Ensure messaging is configured in organization settings

Invalid Code Errors

  • Codes expire after 10 minutes
  • Codes are single-use
  • Ensure no leading/trailing spaces

Rate Limiting

  • OTP requests are rate-limited per email/phone
  • Wait before requesting new codes
  • Implement cooldown UI

What’s Next