Limited Edition Drop Use Case
Learn how to use Fanfare draws and auctions to distribute limited edition products fairly and create excitement.Overview
Limited edition products require careful distribution to ensure fairness while maximizing engagement. Fanfare provides draws (raffles) and auctions to give everyone a fair chance while creating memorable experiences. What you’ll learn:- Choosing between draws and auctions
- Setting up a draw-based release
- Running an auction for premium items
- Building the drop experience
- Managing winners and fulfillment
Prerequisites
- Fanfare account with draws and/or auctions enabled
- Limited edition product ready for release
- Understanding of your customer base
- Fulfillment process for winners
When to Use Each Distribution Method
| Method | Best For | Fairness Model | Revenue |
|---|---|---|---|
| Draw (Raffle) | Mass market, brand building | Equal chance | Fixed price |
| Auction | Collectors, price discovery | Highest bidder | Variable |
| Queue | First-come-first-served | Speed-based | Fixed price |
Draw-Based Release
Step 1: Configure the Draw
Copy
import { FanfareAdminClient } from "@waitify-io/fanfare-admin-sdk";
const adminClient = new FanfareAdminClient({
apiKey: process.env.FANFARE_ADMIN_API_KEY!,
organizationId: process.env.FANFARE_ORGANIZATION_ID!,
});
async function createLimitedEditionDraw() {
const draw = await adminClient.draws.create({
name: "Limited Edition Sneaker Release",
slug: "ltd-sneaker-fall-2024",
// Entry period
entryStart: new Date("2024-09-01T10:00:00Z"),
entryEnd: new Date("2024-09-07T23:59:59Z"),
// Draw timing
drawTime: new Date("2024-09-08T12:00:00Z"),
// Prize configuration
config: {
totalWinners: 500,
// Entry limits
maxEntriesPerConsumer: 1,
requireAuthentication: true,
// Selection method
selectionMethod: "random", // or "weighted" for VIP bonus entries
// Winner notification
notifyWinnersVia: ["email", "sms"],
winnerClaimWindow: 48 * 60 * 60, // 48 hours to claim
// Waitlist for unclaimed prizes
enableWaitlist: true,
waitlistSize: 200,
},
// Product details
prize: {
productId: "sneaker-ltd-fall-2024",
name: "Fall 2024 Limited Edition Sneaker",
price: 250,
description: "Only 500 pairs available worldwide",
imageUrl: "https://your-store.com/images/ltd-sneaker.jpg",
},
// Branding
branding: {
primaryColor: "#1A1A1A",
accentColor: "#FFD700",
logoUrl: "https://your-brand.com/logo.png",
},
});
return draw;
}
Step 2: Build the Entry Experience
Copy
// pages/drops/[slug].tsx
import { FanfareProvider } from "@waitify-io/fanfare-sdk-react";
import { DrawExperience } from "@/components/DrawExperience";
import { ProductShowcase } from "@/components/ProductShowcase";
interface DropPageProps {
drawId: string;
product: Product;
entryStart: string;
entryEnd: string;
drawTime: string;
}
export default function DropPage({ drawId, product, entryStart, entryEnd, drawTime }: DropPageProps) {
return (
<FanfareProvider organizationId={process.env.NEXT_PUBLIC_FANFARE_ORG_ID!} options={{ environment: "production" }}>
<div className="drop-page">
<ProductShowcase product={product} />
<DrawExperience drawId={drawId} entryStart={entryStart} entryEnd={entryEnd} drawTime={drawTime} />
<DropDetails product={product} totalWinners={500} drawTime={drawTime} />
</div>
</FanfareProvider>
);
}
function DropDetails({
product,
totalWinners,
drawTime,
}: {
product: Product;
totalWinners: number;
drawTime: string;
}) {
return (
<section className="drop-details">
<h2>How It Works</h2>
<ol className="steps">
<li>
<strong>Enter the Draw</strong>
<p>Sign up with your email during the entry period. One entry per person.</p>
</li>
<li>
<strong>Wait for the Draw</strong>
<p>Winners will be selected randomly on {new Date(drawTime).toLocaleDateString()}.</p>
</li>
<li>
<strong>Get Notified</strong>
<p>Winners receive an email and SMS with purchase instructions.</p>
</li>
<li>
<strong>Complete Purchase</strong>
<p>You have 48 hours to complete your purchase at ${product.price}.</p>
</li>
</ol>
<div className="drop-stats">
<div className="stat">
<span className="value">{totalWinners}</span>
<span className="label">Available</span>
</div>
<div className="stat">
<span className="value">${product.price}</span>
<span className="label">Price</span>
</div>
</div>
</section>
);
}
Step 3: Draw Experience Component
Copy
// components/DrawExperience.tsx
import { useExperienceJourney } from "@waitify-io/fanfare-sdk-react";
import { useState, useEffect } from "react";
interface DrawExperienceProps {
drawId: string;
entryStart: string;
entryEnd: string;
drawTime: string;
}
export function DrawExperience({ drawId, entryStart, entryEnd, drawTime }: DrawExperienceProps) {
const { journey, state, start } = useExperienceJourney(drawId, { autoStart: true });
const snapshot = state?.snapshot;
const stage = snapshot?.sequenceStage;
const now = Date.now();
const entryStartTime = new Date(entryStart).getTime();
const entryEndTime = new Date(entryEnd).getTime();
const drawTimeMs = new Date(drawTime).getTime();
// Determine phase
const phase =
now < entryStartTime
? "pre_entry"
: now < entryEndTime
? "entry_open"
: now < drawTimeMs
? "entry_closed"
: "drawn";
return (
<div className="draw-experience">
{phase === "pre_entry" && <PreEntryState entryStart={entryStart} />}
{phase === "entry_open" && (
<EntryOpenState snapshot={snapshot} stage={stage} onEnter={start} entryEnd={entryEnd} />
)}
{phase === "entry_closed" && <EntryClosedState snapshot={snapshot} drawTime={drawTime} />}
{phase === "drawn" && <DrawnState snapshot={snapshot} />}
</div>
);
}
function PreEntryState({ entryStart }: { entryStart: string }) {
return (
<div className="pre-entry-state">
<h2>Entry Opens Soon</h2>
<CountdownTimer targetTime={entryStart} />
<div className="notify-me">
<p>Get notified when entry opens</p>
<NotifyMeForm />
</div>
</div>
);
}
function EntryOpenState({
snapshot,
stage,
onEnter,
entryEnd,
}: {
snapshot: JourneySnapshot;
stage: string;
onEnter: () => void;
entryEnd: string;
}) {
const isEntered = stage === "entered" || stage === "waiting";
const entryCount = snapshot?.context?.totalEntries || 0;
if (isEntered) {
return (
<div className="entered-state">
<div className="success-badge">
<span className="icon">✓</span>
<span>You're In!</span>
</div>
<p>Your entry has been recorded. Good luck!</p>
<div className="entry-confirmation">
<p>Entry Number: #{snapshot?.context?.entryNumber}</p>
<p>Total Entries: {entryCount.toLocaleString()}</p>
</div>
<div className="draw-countdown">
<p>Draw happens in:</p>
<CountdownTimer targetTime={snapshot?.context?.drawTime} />
</div>
<p className="reminder">We'll notify you by email if you win.</p>
</div>
);
}
return (
<div className="entry-open-state">
<h2>Enter the Draw</h2>
<div className="entry-stats">
<span className="entries">{entryCount.toLocaleString()} entries so far</span>
</div>
<div className="entry-closing">
<p>Entry closes in:</p>
<CountdownTimer targetTime={entryEnd} />
</div>
<EntryForm onSubmit={onEnter} />
<p className="terms">By entering, you agree to the draw rules and terms.</p>
</div>
);
}
function EntryForm({ onSubmit }: { onSubmit: () => void }) {
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [agreed, setAgreed] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// SDK handles entry
onSubmit();
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="entry-form">
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="[email protected]"
/>
</div>
<div className="form-group">
<label htmlFor="phone">Phone Number (for SMS notification)</label>
<input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 (555) 123-4567"
/>
</div>
<div className="form-group checkbox">
<label>
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} required />I agree to
the draw rules and terms of service
</label>
</div>
<button type="submit" disabled={isSubmitting || !agreed}>
{isSubmitting ? "Entering..." : "Enter Draw"}
</button>
</form>
);
}
function EntryClosedState({ snapshot, drawTime }: { snapshot: JourneySnapshot; drawTime: string }) {
const isEntered = snapshot?.context?.isEntered;
const totalEntries = snapshot?.context?.totalEntries || 0;
return (
<div className="entry-closed-state">
<h2>Entry Period Closed</h2>
{isEntered ? (
<div className="entered-confirmation">
<p className="success">You're entered! Good luck!</p>
<p>Entry Number: #{snapshot?.context?.entryNumber}</p>
</div>
) : (
<p className="missed">Entry is now closed. Follow us for future drops.</p>
)}
<div className="draw-info">
<p>Total entries: {totalEntries.toLocaleString()}</p>
<p>Draw happens:</p>
<CountdownTimer targetTime={drawTime} />
</div>
</div>
);
}
function DrawnState({ snapshot }: { snapshot: JourneySnapshot }) {
const isWinner = snapshot?.context?.isWinner;
const claimDeadline = snapshot?.context?.claimDeadline;
if (isWinner) {
return (
<div className="winner-state">
<div className="winner-celebration">
<h2>Congratulations!</h2>
<p>You've been selected!</p>
</div>
<div className="claim-section">
<p>Complete your purchase before:</p>
<p className="deadline">{new Date(claimDeadline).toLocaleString()}</p>
<button onClick={() => (window.location.href = `/checkout/claim/${snapshot?.context?.claimToken}`)}>
Claim Your Prize
</button>
</div>
<p className="warning">Don't miss out - unclaimed prizes go to the waitlist.</p>
</div>
);
}
return (
<div className="not-selected-state">
<h2>Draw Complete</h2>
<p>Unfortunately, you weren't selected this time.</p>
{snapshot?.context?.waitlistPosition && (
<div className="waitlist-info">
<p>You're on the waitlist at position #{snapshot.context.waitlistPosition}</p>
<p>You'll be notified if a spot opens up.</p>
</div>
)}
<div className="follow-up">
<p>Stay tuned for future drops!</p>
<button onClick={() => (window.location.href = "/subscribe")}>Get Notified of Future Drops</button>
</div>
</div>
);
}
Auction-Based Release
Step 1: Configure the Auction
Copy
async function createLimitedEditionAuction() {
const auction = await adminClient.auctions.create({
name: "Rare Collector's Edition Artwork",
slug: "rare-artwork-auction-001",
// Timing
startTime: new Date("2024-10-01T18:00:00Z"),
endTime: new Date("2024-10-03T18:00:00Z"),
// Auction configuration
config: {
auctionType: "english", // ascending price auction
// Pricing
startingBid: 500,
reservePrice: 1000, // Minimum price to sell
bidIncrement: 50,
buyNowPrice: 5000, // Optional instant purchase
// Anti-sniping
antiSnipingEnabled: true,
antiSnipingExtension: 120, // Extend 2 minutes if bid in last 2 minutes
// Bidder requirements
requireAuthentication: true,
requirePaymentMethod: true, // Must have card on file
bidderDeposit: 100, // Refundable deposit to bid
// Limits
maxBidsPerConsumer: 50,
},
// Item details
item: {
productId: "artwork-rare-001",
name: "Original Digital Artwork #001",
description: "One-of-a-kind digital artwork with physical print",
imageUrl: "https://your-store.com/images/artwork-001.jpg",
attributes: {
artist: "Famous Artist",
medium: "Digital + Physical Print",
size: "24x36 inches",
certificate: "Includes Certificate of Authenticity",
},
},
// Branding
branding: {
primaryColor: "#2C2C2C",
accentColor: "#C9A227",
},
});
return auction;
}
Step 2: Build the Auction Experience
Copy
// components/AuctionExperience.tsx
import { useExperienceJourney } from "@waitify-io/fanfare-sdk-react";
import { useState, useEffect, useCallback } from "react";
interface AuctionExperienceProps {
auctionId: string;
item: AuctionItem;
}
export function AuctionExperience({ auctionId, item }: AuctionExperienceProps) {
const { journey, state, start } = useExperienceJourney(auctionId, { autoStart: true });
const snapshot = state?.snapshot;
const context = snapshot?.context;
const currentBid = context?.currentBid || context?.startingBid;
const highBidderId = context?.highBidderId;
const myId = context?.consumerId;
const isHighBidder = highBidderId === myId;
const endTime = context?.endTime;
const bidIncrement = context?.bidIncrement || 50;
return (
<div className="auction-experience">
<AuctionHeader item={item} endTime={endTime} />
<div className="auction-main">
<div className="current-bid-section">
<span className="label">Current Bid</span>
<span className="amount">${currentBid?.toLocaleString()}</span>
{context?.totalBids && <span className="bid-count">{context.totalBids} bids</span>}
</div>
{isHighBidder && (
<div className="high-bidder-notice">
<span className="icon">✓</span>
You're the highest bidder!
</div>
)}
<BidForm
currentBid={currentBid}
minBid={currentBid + bidIncrement}
bidIncrement={bidIncrement}
buyNowPrice={context?.buyNowPrice}
isHighBidder={isHighBidder}
onBid={(amount) => placeBid(journey, amount)}
onBuyNow={() => buyNow(journey)}
/>
<BidHistory bids={context?.recentBids || []} myId={myId} />
</div>
</div>
);
}
function AuctionHeader({ item, endTime }: { item: AuctionItem; endTime?: number }) {
const [timeLeft, setTimeLeft] = useState(0);
const [isEnding, setIsEnding] = useState(false);
useEffect(() => {
if (!endTime) return;
const interval = setInterval(() => {
const remaining = endTime - Date.now();
setTimeLeft(Math.max(0, remaining));
setIsEnding(remaining < 5 * 60 * 1000); // Less than 5 minutes
}, 1000);
return () => clearInterval(interval);
}, [endTime]);
return (
<header className="auction-header">
<h1>{item.name}</h1>
<div className={`auction-timer ${isEnding ? "ending-soon" : ""}`}>
<span className="label">{isEnding ? "Ending Soon!" : "Time Remaining"}</span>
<span className="time">{formatAuctionTime(timeLeft)}</span>
</div>
</header>
);
}
function BidForm({
currentBid,
minBid,
bidIncrement,
buyNowPrice,
isHighBidder,
onBid,
onBuyNow,
}: {
currentBid: number;
minBid: number;
bidIncrement: number;
buyNowPrice?: number;
isHighBidder: boolean;
onBid: (amount: number) => void;
onBuyNow: () => void;
}) {
const [bidAmount, setBidAmount] = useState(minBid);
const [isSubmitting, setIsSubmitting] = useState(false);
// Update minimum when current bid changes
useEffect(() => {
if (bidAmount < minBid) {
setBidAmount(minBid);
}
}, [minBid, bidAmount]);
const handleBid = async () => {
if (bidAmount < minBid) return;
setIsSubmitting(true);
try {
await onBid(bidAmount);
} finally {
setIsSubmitting(false);
}
};
const quickBidAmounts = [minBid, minBid + bidIncrement, minBid + bidIncrement * 2, minBid + bidIncrement * 5];
return (
<div className="bid-form">
<div className="quick-bids">
{quickBidAmounts.map((amount) => (
<button key={amount} onClick={() => setBidAmount(amount)} className={bidAmount === amount ? "selected" : ""}>
${amount.toLocaleString()}
</button>
))}
</div>
<div className="custom-bid">
<label htmlFor="bid-amount">Your Bid</label>
<div className="bid-input-group">
<span className="currency">$</span>
<input
id="bid-amount"
type="number"
value={bidAmount}
onChange={(e) => setBidAmount(Math.max(minBid, Number(e.target.value)))}
min={minBid}
step={bidIncrement}
/>
</div>
<span className="min-bid">Minimum: ${minBid.toLocaleString()}</span>
</div>
<button onClick={handleBid} disabled={isSubmitting || bidAmount < minBid} className="place-bid-btn">
{isSubmitting ? "Placing Bid..." : isHighBidder ? "Increase Bid" : "Place Bid"}
</button>
{buyNowPrice && (
<div className="buy-now-section">
<span className="divider">or</span>
<button onClick={onBuyNow} className="buy-now-btn">
Buy Now for ${buyNowPrice.toLocaleString()}
</button>
</div>
)}
</div>
);
}
function BidHistory({ bids, myId }: { bids: Bid[]; myId: string }) {
if (bids.length === 0) {
return (
<div className="bid-history empty">
<p>No bids yet. Be the first!</p>
</div>
);
}
return (
<div className="bid-history">
<h3>Recent Bids</h3>
<ul>
{bids.map((bid, index) => (
<li key={bid.id} className={bid.bidderId === myId ? "my-bid" : ""}>
<span className="bidder">{bid.bidderId === myId ? "You" : `Bidder ${bid.bidderNumber}`}</span>
<span className="amount">${bid.amount.toLocaleString()}</span>
<span className="time">{formatTimeAgo(bid.timestamp)}</span>
{index === 0 && <span className="leader-badge">Leading</span>}
</li>
))}
</ul>
</div>
);
}
async function placeBid(journey: ExperienceJourney, amount: number) {
await journey.perform("bid", { amount });
}
async function buyNow(journey: ExperienceJourney) {
await journey.perform("buyNow");
}
function formatAuctionTime(ms: number): string {
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((ms % (1000 * 60)) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return "Just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return new Date(timestamp).toLocaleDateString();
}
Step 3: Winner Management
Process Draw Winners
Copy
// services/draw-winner-management.ts
async function processDrawWinners(drawId: string) {
// Get winners list
const winners = await adminClient.draws.getWinners(drawId);
for (const winner of winners) {
// Generate unique claim token
const claimToken = await generateClaimToken(winner.consumerId, drawId);
// Store claim data
await db.insert(drawClaims).values({
drawId,
consumerId: winner.consumerId,
claimToken,
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000), // 48 hours
status: "pending",
});
// Send notifications
await sendWinnerNotification(winner, claimToken);
}
// Process waitlist
const waitlist = await adminClient.draws.getWaitlist(drawId);
for (const entry of waitlist) {
await sendWaitlistNotification(entry);
}
}
async function sendWinnerNotification(winner: DrawWinner, claimToken: string) {
await Promise.all([
sendEmail({
to: winner.email,
subject: "You Won! Complete Your Purchase",
template: "draw-winner",
data: {
name: winner.name,
productName: winner.prize.name,
claimUrl: `https://your-store.com/claim/${claimToken}`,
deadline: new Date(Date.now() + 48 * 60 * 60 * 1000).toLocaleString(),
},
}),
sendSMS({
to: winner.phone,
message: `Congratulations! You won the ${winner.prize.name}. Claim within 48h: https://your-store.com/claim/${claimToken}`,
}),
]);
}
Process Auction Winner
Copy
async function processAuctionWinner(auctionId: string) {
const auction = await adminClient.auctions.get(auctionId);
if (auction.status !== "ended") {
throw new Error("Auction not yet ended");
}
const winner = auction.highestBidder;
const finalPrice = auction.highestBid;
// Check reserve price met
if (finalPrice < auction.config.reservePrice) {
await handleReserveNotMet(auction);
return;
}
// Generate payment intent for final amount
const paymentIntent = await stripe.paymentIntents.create({
amount: finalPrice * 100,
currency: "usd",
customer: winner.stripeCustomerId,
metadata: {
auctionId,
type: "auction_win",
},
});
// Send winner notification
await sendEmail({
to: winner.email,
subject: `Congratulations! You won the auction for ${auction.item.name}`,
template: "auction-winner",
data: {
itemName: auction.item.name,
finalPrice,
paymentUrl: `https://your-store.com/auction/pay/${auctionId}?pi=${paymentIntent.id}`,
deadline: new Date(Date.now() + 72 * 60 * 60 * 1000).toLocaleString(),
},
});
}
async function handleReserveNotMet(auction: Auction) {
// Notify seller
await sendEmail({
to: auction.sellerEmail,
subject: `Reserve Not Met - ${auction.item.name}`,
template: "auction-reserve-not-met",
data: {
itemName: auction.item.name,
highestBid: auction.highestBid,
reservePrice: auction.config.reservePrice,
},
});
// Notify high bidder
if (auction.highestBidder) {
await sendEmail({
to: auction.highestBidder.email,
subject: `Auction Ended - Reserve Not Met`,
template: "auction-reserve-not-met-bidder",
data: {
itemName: auction.item.name,
yourBid: auction.highestBid,
},
});
}
}
Best Practices
1. Prevent Gaming and Fraud
Copy
// Draw fraud prevention
const drawFraudPrevention = {
// Require verified email
requireEmailVerification: true,
// Block disposable emails
blockDisposableEmails: true,
// Limit entries by IP
maxEntriesPerIP: 3,
// Require account age
minAccountAgeDays: 7,
// Geographic restrictions
allowedCountries: ["US", "CA", "UK"],
};
// Auction fraud prevention
const auctionFraudPrevention = {
// Require verified payment method
requirePaymentMethod: true,
// Bidder deposit
bidderDeposit: 100,
// Account verification
requireIdentityVerification: true,
// Shill bidding detection
detectShillBidding: true,
};
2. Clear Communication
Copy
// Communication timeline for draws
const drawCommunicationSchedule = [
{ trigger: "entry_open", template: "draw-entry-open", channels: ["email"] },
{ trigger: "24h_before_close", template: "draw-reminder", channels: ["email", "push"] },
{ trigger: "entry_closed", template: "draw-closed", channels: ["email"] },
{ trigger: "draw_complete_winner", template: "draw-winner", channels: ["email", "sms", "push"] },
{ trigger: "draw_complete_non_winner", template: "draw-non-winner", channels: ["email"] },
{ trigger: "24h_before_claim_deadline", template: "claim-reminder", channels: ["email", "sms"] },
{ trigger: "claim_expired", template: "claim-expired-waitlist", channels: ["email", "sms"] },
];
3. Transparent Rules
Copy
function DrawRules({ draw }: { draw: Draw }) {
return (
<section className="draw-rules">
<h2>Official Rules</h2>
<dl>
<dt>Eligibility</dt>
<dd>Must be 18+ and resident of eligible countries</dd>
<dt>Entry Period</dt>
<dd>
{new Date(draw.entryStart).toLocaleString()} - {new Date(draw.entryEnd).toLocaleString()}
</dd>
<dt>Entry Limit</dt>
<dd>One entry per person</dd>
<dt>Selection</dt>
<dd>Winners selected randomly using certified random number generator</dd>
<dt>Notification</dt>
<dd>Winners notified via email and SMS within 1 hour of draw</dd>
<dt>Claim Period</dt>
<dd>Winners must complete purchase within 48 hours</dd>
<dt>Price</dt>
<dd>${draw.prize.price} (no additional fees)</dd>
</dl>
</section>
);
}
Troubleshooting
Draw Issues
| Problem | Cause | Solution |
|---|---|---|
| Duplicate entries | Multiple accounts | Implement email/phone verification |
| Low participation | Poor marketing timing | Announce earlier, extend entry period |
| Many unclaimed prizes | Short claim window | Extend to 72h, improve notifications |
Auction Issues
| Problem | Cause | Solution |
|---|---|---|
| Sniping complaints | Last-second bids | Enable anti-sniping extension |
| Low bidding activity | High starting price | Lower start, keep reserve |
| Winner doesn’t pay | No deposit required | Implement bidder deposits |
What’s Next
- Appointment Booking - Service scheduling
- Event Ticketing - Event access management
- Webhooks Guide - Real-time notifications