Skip to main content
Ticket Hub uses Stripe Checkout to handle secure payment processing for ticket purchases. The system integrates with Stripe Connect to enable direct payouts to event organizers.

Ticket Schema

Tickets are stored with the following schema:
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"])

Purchase Flow

1

Join Waiting List

When a user wants to purchase a ticket, they first join the waiting list. If tickets are available, they immediately receive an offer.
Ticket offers expire after 30 minutes to ensure fair access to all users.
2

Receive Offer

If the user receives an offer, a countdown timer displays the remaining time to complete the purchase.From src/components/purchase-ticket.tsx:29-54:
const offerExpiresAt = queuePosition?.offerExpiresAt ?? 0;
const isExpired = Date.now() > offerExpiresAt;

useEffect(() => {
  const calculateTimeRemaining = () => {
    if (isExpired) {
      setTimeRemaining("Expired");
      return;
    }

    const diff = offerExpiresAt - Date.now();
    const minutes = Math.floor(diff / 1000 / 60);
    const seconds = Math.floor((diff / 1000) % 60);

    if (minutes > 0) {
      setTimeRemaining(
        `${minutes} minute${minutes === 1 ? "" : "s"} ${seconds} second${
          seconds === 1 ? "" : "s"
        }`
      );
    } else {
      setTimeRemaining(`${seconds} second${seconds === 1 ? "" : "s"}`);
    }
  };

  calculateTimeRemaining();
  const interval = setInterval(calculateTimeRemaining, 1000);
  return () => clearInterval(interval);
}, [offerExpiresAt, isExpired]);
3

Stripe Checkout

Users click “Purchase Your Ticket Now” and are redirected to Stripe Checkout for secure payment processing.
const handlePurchase = async () => {
  if (!user) return;

  try {
    setIsLoading(true);
    const { sessionUrl } = await createStripeCheckoutSession({
      eventId,
    });

    if (sessionUrl) {
      router.push(sessionUrl);
    }
  } catch (error) {
    console.error("Error creating checkout session:", error);
  } finally {
    setIsLoading(false);
  }
};
4

Payment Confirmation

After successful payment, Stripe redirects the user back to the success page with their ticket details.
5

Receive Ticket with QR Code

The ticket is generated with a unique QR code for event check-in.

Stripe Integration

Payment Intent Creation

Ticket Hub uses Stripe’s Payment Intent API with Connect to handle payments:
Application Fee: Ticket Hub charges a 1% platform fee on each ticket sale, automatically deducted during payment processing.
payment_intent_data: {
  application_fee_amount: Math.round(event.price * 100 * 0.01),
}

Checkout Session Configuration

payment_method_types
array
Supported payment methods (currently card only)
expires_at
number
Session expiration time (30 minutes to match ticket offer duration)
metadata
object
Custom data passed through the checkout process:
  • eventId: Event identifier
  • userId: Buyer’s user ID
  • waitingListId: Reference to the ticket offer
stripeAccount
string
Event organizer’s Stripe Connect account ID for direct payout

Ticket Status Lifecycle

From convex/constant.ts:17-22:
export const TICKET_STATUS: Record<string, Doc<"tickets">["status"]> = {
  VALID: "valid",
  USED: "used",
  REFUNDED: "refunded",
  CANCELLED: "cancelled",
} as const;
1

valid

Ticket is purchased and can be used for event entry
2

used

Ticket has been scanned and used for event entry
3

refunded

Ticket was refunded and is no longer valid
4

cancelled

Event was cancelled and ticket is invalidated

Update Ticket Status

From convex/tickets.ts:50-63:
export const updateTicketStatus = mutation({
  args: {
    ticketId: v.id("tickets"),
    status: v.union(
      v.literal("valid"),
      v.literal("used"),
      v.literal("refunded"),
      v.literal("cancelled")
    ),
  },
  handler: async (ctx, { ticketId, status }) => {
    await ctx.db.patch(ticketId, { status });
  },
});

QR Code Generation

Each ticket includes a unique QR code for event check-in. The QR code contains the ticket ID.

Ticket Display Component

From src/components/ticket.tsx:126-136:
{/* Right Column - QR Code */}
<div className="flex flex-col items-center justify-center border-l border-gray-200 pl-6">
  <div
    className={`bg-gray-100 p-4 rounded-lg ${ticket.event.is_cancelled ? "opacity-50" : ""}`}
  >
    <QRCode value={ticket._id} className="w-32 h-32" />
  </div>
  <p className="mt-2 text-sm text-gray-500 break-all text-center max-w-[200px] md:max-w-full">
    Ticket ID: {ticket._id}
  </p>
</div>
The QR code encodes the ticket’s unique ID, which can be scanned at the event for verification.

Ticket Queries

Get User’s Tickets

Retrieve all tickets for a specific user with event information:
export const getUserTickets = query({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    const tickets = await ctx.db
      .query("tickets")
      .withIndex("by_user", (q) => q.eq("userId", userId))
      .collect();

    const ticketsWithEvents = await Promise.all(
      tickets.map(async (ticket) => {
        const event = await ctx.db.get(ticket.eventId);
        return {
          ...ticket,
          event,
        };
      })
    );

    return ticketsWithEvents;
  },
});

Get Ticket with Details

From convex/tickets.ts:22-35:
export const getTicketWithDetails = query({
  args: { ticketId: v.id("tickets") },
  handler: async (ctx, { ticketId }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) return null;

    const event = await ctx.db.get(ticket.eventId);

    return {
      ...ticket,
      event,
    };
  },
});

Release Ticket Offer

Users can release their ticket offer before purchasing, which makes it available to the next person in the waiting list.
From src/components/release-ticket.tsx:10-46:
export default function ReleaseTicket({
  eventId,
  waitingListId,
}: {
  eventId: Id<"events">;
  waitingListId: Id<"waitingList">;
}) {
  const [isReleasing, setIsReleasing] = useState(false);
  const releaseTicket = useMutation(api.waiting_list.releaseTicket);

  const handleRelease = async () => {
    if (!confirm("Are you sure you want to release your ticket offer?")) return;

    try {
      setIsReleasing(true);
      await releaseTicket({
        eventId,
        waitingListId,
      });
    } catch (error) {
      console.error("Error releasing ticket:", error);
    } finally {
      setIsReleasing(false);
    }
  };

  return (
    <button
      onClick={handleRelease}
      disabled={isReleasing}
      className="mt-2 w-full flex cursor-pointer items-center justify-center gap-2 py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
    >
      <XCircle className="w-4 h-4" />
      {isReleasing ? "Releasing..." : "Release Ticket Offer"}
    </button>
  );
}

Next Steps

Waiting List

Learn about the queue system and ticket offers

Seller Dashboard

View ticket sales and revenue

Build docs developers (and LLMs) love