Skip to main content
Ticket Hub is a full-featured event ticketing platform built with modern web technologies. Here’s what makes it powerful:

Smart Waiting List System

Automatically manages ticket availability with time-limited offers and queue processing

Stripe Connect Integration

Seamless payment processing with direct payouts to event organizers

Real-time Event Search

Fast, client-side search across event names, descriptions, and locations

Automated Refund System

One-click refunds for all tickets when events are cancelled

Rate Limiting Protection

Built-in rate limiting prevents abuse of the waiting list system

Clerk Authentication

Secure user authentication with support for multiple sign-in methods

Seller Dashboard

Comprehensive analytics and management tools for event organizers

Automated Cleanup Jobs

Background cron jobs ensure data consistency and expire old offers

Smart Waiting List System

The waiting list system is the core feature of Ticket Hub. When tickets sell out, users can join a queue and receive time-limited offers when tickets become available.

How It Works

1

User joins waiting list

When a user attempts to join the waiting list, the system checks ticket availability:
// From convex/events.ts:145-156
const { available } = await checkAvailability(ctx, eventId);

if (available) {
  // If tickets are available, create an offer entry
  const waitingListId = await ctx.db.insert("waitingList", {
    eventId,
    userId,
    status: WAITING_LIST_STATUS.OFFERED,
    offerExpiresAt: now + DURATIONS.TICKET_OFFER, // 30 minutes
  });
}
2

Offer expires or ticket purchased

Each offer has a 30-minute expiration window. If the user doesn’t purchase, the offer automatically expires:
// From convex/waiting_list.ts:173-187
export const expireOffer = internalMutation({
  args: {
    waitingListId: v.id("waitingList"),
    eventId: v.id("events"),
  },
  handler: async (ctx, { waitingListId, eventId }) => {
    const offer = await ctx.db.get(waitingListId);
    if (!offer || offer.status !== WAITING_LIST_STATUS.OFFERED) return;
    
    await ctx.db.patch(waitingListId, {
      status: WAITING_LIST_STATUS.EXPIRED,
    });
    
    await processQueueX(ctx, eventId);
  },
});
3

Next person in queue gets offer

When a ticket becomes available, the system automatically processes the queue:
// From convex/waiting_list.ts:131-148
const waitingUsers = await ctx.db
  .query("waitingList")
  .withIndex("by_event_status", (q) =>
    q.eq("eventId", eventId).eq("status", WAITING_LIST_STATUS.WAITING)
  )
  .order("asc")
  .take(availableSpots);

for (const user of waitingUsers) {
  await ctx.db.patch(user._id, {
    status: WAITING_LIST_STATUS.OFFERED,
    offerExpiresAt: now + DURATIONS.TICKET_OFFER,
  });
  
  await ctx.scheduler.runAfter(
    DURATIONS.TICKET_OFFER,
    internal.waiting_list.expireOffer,
    { waitingListId: user._id, eventId }
  );
}
The 30-minute offer window is enforced both in the application and at the Stripe checkout level to ensure users have enough time to complete their purchase.

Queue Position Tracking

Users can see their real-time position in the queue:
// From convex/waiting_list.ts:37-76
export const getQueuePosition = query({
  args: {
    eventId: v.id("events"),
    userId: v.string(),
  },
  handler: async (ctx, { eventId, userId }) => {
    const entry = await ctx.db
      .query("waitingList")
      .withIndex("by_user_event", (q) =>
        q.eq("userId", userId).eq("eventId", eventId)
      )
      .first();
    
    if (!entry) return null;
    
    // Count people ahead in line
    const peopleAhead = await ctx.db
      .query("waitingList")
      .withIndex("by_event_status", (q) => q.eq("eventId", eventId))
      .filter((q) =>
        q.and(
          q.lt(q.field("_creationTime"), entry._creationTime),
          q.or(
            q.eq(q.field("status"), "waiting"),
            q.eq(q.field("status"), "offered")
          )
        )
      )
      .collect()
      .then((entries) => entries.length);
    
    return {
      ...entry,
      position: peopleAhead + 1,
    };
  },
});

Stripe Connect Integration

Ticket Hub uses Stripe Connect to enable event organizers to receive payments directly to their own Stripe accounts, with a 1% platform fee.

Account Setup Flow

Event organizers go through a streamlined onboarding process:
1

Create Stripe Express account

// From src/actions/create-stripe-connect-customer.ts:41-47
const account = await stripe.accounts.create({
  type: "express",
  capabilities: {
    card_payments: { requested: true },
    transfers: { requested: true },
  },
});
2

Complete account requirements

Users provide required information through Stripe’s hosted onboarding:
// From src/actions/create-stripe-account-link.ts
const accountLink = await stripe.accountLinks.create({
  account: stripeConnectId,
  refresh_url: `${baseUrl}/connect/refresh/${stripeConnectId}`,
  return_url: `${baseUrl}/connect/return/${stripeConnectId}`,
  type: "account_onboarding",
});
3

Start accepting payments

Once approved, organizers can create events and accept payments with automatic platform fees.

Payment Processing

When a user purchases a ticket, the payment is split automatically:
// From src/actions/create-stripe-checkout-session.ts:65-93
const session = await stripe.checkout.sessions.create(
  {
    payment_method_types: ["card"],
    line_items: [
      {
        price_data: {
          currency: "gbp",
          product_data: {
            name: event.name,
            description: event.description,
          },
          unit_amount: Math.round(event.price * 100),
        },
        quantity: 1,
      },
    ],
    payment_intent_data: {
      application_fee_amount: Math.round(event.price * 100 * 0.01), // 1% fee
    },
    expires_at: Math.floor(Date.now() / 1000) + DURATIONS.TICKET_OFFER / 1000,
    mode: "payment",
    success_url: `${baseUrl}/tickets/purchase-success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${baseUrl}/event/${eventId}`,
    metadata,
  },
  {
    stripeAccount: stripeConnectId,
  }
);
The 1% platform fee is automatically deducted by Stripe. Event organizers receive 99% of the ticket price directly to their connected account.

The search functionality filters events by name, description, and location in real-time:
// From convex/events.ts:370-387
export const search = query({
  args: { searchTerm: v.string() },
  handler: async (ctx, { searchTerm }) => {
    const events = await ctx.db
      .query("events")
      .filter((q) => q.eq(q.field("is_cancelled"), undefined))
      .collect();
    
    return events.filter((event) => {
      const searchTermLower = searchTerm.toLowerCase();
      return (
        event.name.toLowerCase().includes(searchTermLower) ||
        event.description.toLowerCase().includes(searchTermLower) ||
        event.location.toLowerCase().includes(searchTermLower)
      );
    });
  },
});

Search Component

The search bar provides a clean user interface:
// From src/components/search-bar.tsx:4-24
export default function SearchBar() {
  return (
    <div className="w-full max-w-4xl mx-auto">
      <Form action="/search" className="relative">
        <input
          type="text"
          name="q"
          placeholder="Search for events..."
          className="w-full py-2 px-4 pl-12 bg-white rounded-lg border border-gray-200 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
        <button
          type="submit"
          className="absolute cursor-pointer right-3 top-1/2 -translate-y-1/2 bg-blue-600 text-white px-4 py-1 rounded-lg text-sm font-medium hover:bg-blue-700"
        >
          Search
        </button>
      </Form>
    </div>
  );
}

Automated Refund System

When an event is cancelled, the system automatically processes refunds for all valid tickets:
// From src/actions/refund-event-ticket.ts:34-64
const results = await Promise.allSettled(
  tickets.map(async (ticket) => {
    try {
      if (!ticket.paymentIntentId) {
        throw new Error("Payment information not found");
      }
      
      // Issue refund through Stripe
      await stripe.refunds.create(
        {
          payment_intent: ticket.paymentIntentId,
          reason: "requested_by_customer",
        },
        {
          stripeAccount: stripeConnectId,
        }
      );
      
      // Update ticket status to refunded
      await convex.mutation(api.tickets.updateTicketStatus, {
        ticketId: ticket._id,
        status: "refunded",
      });
      
      return { success: true, ticketId: ticket._id };
    } catch (error) {
      console.error(`Failed to refund ticket ${ticket._id}:`, error);
      return { success: false, ticketId: ticket._id, error };
    }
  })
);
Event organizers cannot cancel events that have active tickets. All tickets must be refunded first to protect buyers.

Rate Limiting Protection

The system prevents abuse by limiting how often users can join waiting lists:
// From convex/events.ts:17-24
const rateLimiter = new RateLimiter(components.rateLimiter, {
  queueJoin: {
    kind: "fixed window",
    rate: 3, // 3 joins allowed
    period: 30 * MINUTE, // in 30 minutes
  },
});
When a user tries to join too many waiting lists:
// From convex/events.ts:116-123
const status = await rateLimiter.limit(ctx, "queueJoin", { key: userId });
if (!status.ok) {
  throw new ConvexError(
    `You've joined the waiting list too many times. Please wait ${Math.ceil(
      status.retryAfter / (60 * 1000)
    )} minutes before trying again.`
  );
}
Rate limiting is applied per user and tracks joins across all events to prevent system abuse.

Clerk Authentication

Ticket Hub uses Clerk for secure, flexible authentication:
// From convex/auth.config.ts:2-9
export default {
  providers: [
    {
      domain: "https://eminent-rooster-80.clerk.accounts.dev",
      applicationID: "convex",
    },
  ],
};
Authentication is required for all ticket purchases and event management:
// From src/actions/create-stripe-checkout-session.ts:24-25
const { userId } = await auth();
if (!userId) throw new Error("Not authenticated");

Seller Dashboard

Event organizers get a comprehensive dashboard with:
  • Account status monitoring - Real-time Stripe Connect account status
  • Payment capability checks - Verification of charge and payout abilities
  • Event metrics - Track sold, refunded, and cancelled tickets
  • Revenue tracking - View earnings per event
// From convex/events.ts:399-424
const eventsWithMetrics = await Promise.all(
  events.map(async (event) => {
    const tickets = await ctx.db
      .query("tickets")
      .withIndex("by_event", (q) => q.eq("eventId", event._id))
      .collect();
    
    const validTickets = tickets.filter(
      (t) => t.status === "valid" || t.status === "used"
    );
    const refundedTickets = tickets.filter((t) => t.status === "refunded");
    const cancelledTickets = tickets.filter((t) => t.status === "cancelled");
    
    const metrics: Metrics = {
      soldTickets: validTickets.length,
      refundedTickets: refundedTickets.length,
      cancelledTickets: cancelledTickets.length,
      revenue: validTickets.length * event.price,
    };
    
    return {
      ...event,
      metrics,
    };
  })
);

Automated Cleanup Jobs

A cron job runs every minute to clean up expired offers and maintain data consistency:
// From convex/crons.ts:7-11
crons.interval(
  "cleanup-expired-offers",
  { minutes: 1 },
  internal.waiting_list.cleanupExpiredOffers
);
The cleanup process:
// From convex/waiting_list.ts:199-229
export const cleanupExpiredOffers = internalMutation({
  args: {},
  handler: async (ctx) => {
    const now = Date.now();
    
    // Find all expired but not yet cleaned up offers
    const expiredOffers = await ctx.db
      .query("waitingList")
      .filter((q) =>
        q.and(
          q.eq(q.field("status"), WAITING_LIST_STATUS.OFFERED),
          q.lt(q.field("offerExpiresAt"), now)
        )
      )
      .collect();
    
    // Group by event for batch processing
    const grouped = groupByEvent(expiredOffers);
    
    // Process each event's expired offers and update queue
    for (const [eventId, offers] of Object.entries(grouped)) {
      await Promise.all(
        offers.map((offer) =>
          ctx.db.patch(offer._id, {
            status: WAITING_LIST_STATUS.EXPIRED,
          })
        )
      );
      
      await processQueueX(ctx, eventId as Id<"events">);
    }
  },
});
The cleanup job acts as a fail-safe. Individual offers are designed to expire via scheduled jobs, but this ensures any offers that weren’t properly expired (due to server issues) are caught and cleaned up.

Database Schema

All features are built on a robust Convex schema:
// From convex/schema.ts:4-58
export default defineSchema({
  events: defineTable({
    name: v.string(),
    description: v.string(),
    location: v.string(),
    eventDate: v.number(),
    price: v.number(),
    totalTickets: v.number(),
    userId: v.string(),
    imageStorageId: v.optional(v.id("_storage")),
    is_cancelled: v.optional(v.boolean()),
  }),
  
  tickets: defineTable({
    eventId: v.id("events"),
    userId: v.string(),
    purchasedAt: v.number(),
    status: v.union(
      v.literal("valid"),
      v.literal("used"),
      v.literal("refunded"),
      v.literal("cancelled")
    ),
    paymentIntentId: v.optional(v.string()),
    amount: v.optional(v.number()),
  })
    .index("by_event", ["eventId"])
    .index("by_user", ["userId"])
    .index("by_user_event", ["userId", "eventId"])
    .index("by_payment_intent", ["paymentIntentId"]),
  
  waitingList: defineTable({
    eventId: v.id("events"),
    userId: v.string(),
    status: v.union(
      v.literal("waiting"),
      v.literal("offered"),
      v.literal("purchased"),
      v.literal("expired")
    ),
    offerExpiresAt: v.optional(v.number()),
  })
    .index("by_event_status", ["eventId", "status"])
    .index("by_user_event", ["userId", "eventId"])
    .index("by_user", ["userId"]),
  
  users: defineTable({
    name: v.string(),
    email: v.string(),
    userId: v.string(),
    phone: v.optional(v.string()),
    stripeConnectId: v.optional(v.string()),
  })
    .index("by_user_id", ["userId"])
    .index("by_email", ["email"]),
});
The schema uses strategic indexes to optimize common queries:
  • by_event and by_user for fast lookups
  • by_user_event for checking if a user already has a ticket
  • by_event_status for efficient waiting list queries
  • by_payment_intent for webhook processing

Build docs developers (and LLMs) love