Build a live auction platform with Replit in 2-4 hours using Express, PostgreSQL, Drizzle ORM, and Stripe. You'll get time-limited listings, race-condition-safe bid placement with PostgreSQL transactions, countdown timers, anti-sniping logic, and post-auction Stripe Checkout for winners.
What you're building
An auction platform is one of the most technically demanding app types: it requires real-time state updates, race-condition-proof bidding, time-based automation, and deferred payment collection. Think eBay — sellers list items with a starting price and an end time, buyers compete by placing incrementally higher bids, and the winner pays after the auction closes.
Replit Agent generates the Express + Drizzle foundation quickly. The hard parts — the database-level bid locking, anti-sniping trigger, and Stripe post-auction checkout — are built step by step in this guide. Use the /stripe command in Replit to auto-provision a Stripe sandbox environment with keys and webhook wiring.
The most important architectural decision: deploy on Reserved VM, not Autoscale. A cold start during the final seconds of an auction would be catastrophic — a bidder's winning bid would time out before the server woke up. Reserved VM keeps the bid endpoint always-on. The polling approach for real-time updates avoids WebSocket complexity while still showing live bid activity.
Final result
A live auction platform with listing creation, real-time bid updates via polling, race-condition-safe bid placement, anti-sniping extension, and Stripe Checkout payment collection for auction winners.
Tech stack
Prerequisites
- A Replit Core account (required for Replit Auth and built-in PostgreSQL)
- A Stripe account — sign up free at stripe.com (test mode is free, no real charges)
- Basic understanding of what a database transaction means (no coding experience needed)
- Reserved VM deployment plan — auctions need an always-on server ($10-20/month on Replit)
Build steps
Scaffold the project and database schema with Agent
Generate the full Express + Drizzle project with the auction schema. Getting the schema right now saves significant rework later — auctions, bids, sellers, and watchlist all need specific column types.
1// Prompt to type into Replit Agent:2// Build a Node.js Express auction platform with Replit Auth and built-in PostgreSQL using Drizzle ORM.3// Schema in shared/schema.ts:4// * sellers: id serial pk, user_id text not null unique, display_name text not null,5// stripe_customer_id text, verified boolean default false, created_at timestamp default now()6// * auctions: id serial pk, seller_id integer references sellers(id) not null,7// title text not null, description text, images jsonb, starting_price integer not null,8// reserve_price integer, current_bid integer default 0, bid_count integer default 0,9// status text default 'draft', start_time timestamp not null, end_time timestamp not null,10// category text, created_at timestamp default now()11// * bids: id serial pk, auction_id integer references auctions(id) not null,12// bidder_id text not null, amount integer not null, created_at timestamp default now()13// * watchlist: id serial pk, user_id text not null, auction_id integer references auctions(id) not null,14// unique constraint on (user_id, auction_id)15// * webhook_events: id serial pk, stripe_event_id text unique not null, event_type text,16// processed_at timestamp default now()17// Routes: POST /api/auctions, GET /api/auctions, GET /api/auctions/:id,18// POST /api/auctions/:id/bids, POST /api/auctions/:id/watch,19// POST /api/auctions/:id/pay, POST /api/webhooks/stripe20// React frontend with auction card grid, countdown timers, bid history table, bid input formPro tip: All prices are stored in cents (integer) — never store decimal dollar amounts in PostgreSQL for financial data. Display them divided by 100 in the React frontend.
Expected result: Replit creates the project structure with all tables in shared/schema.ts and placeholder route handlers for each endpoint.
Run the /stripe command and configure Stripe webhook
Replit's /stripe command auto-provisions a Stripe sandbox, installs the SDK, and pre-wires the webhook endpoint. After running it, add your Stripe keys to the Secrets panel.
1// In the Replit Agent chat or Shell, type: /stripe2// This automatically:3// 1. Installs the stripe npm package4// 2. Sets up STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY in your workspace5// 3. Creates a basic webhook handler at POST /api/webhooks/stripe6// 4. Adds stripe.webhooks.constructEvent() verification7//8// IMPORTANT: After running /stripe, open the Secrets panel (lock icon 🔒)9// and verify these are set:10// STRIPE_SECRET_KEY=sk_test_...11// STRIPE_PUBLISHABLE_KEY=pk_test_...12// STRIPE_WEBHOOK_SECRET=whsec_... (you'll get this from Stripe Dashboard after deploying)13//14// The webhook secret is only available after deployment.15// Deploy first, then register your deployed URL in Stripe Dashboard:16// Stripe Dashboard → Developers → Webhooks → Add endpoint17// URL: https://your-repl.replit.app/api/webhooks/stripe18// Events: checkout.session.completed19// Copy the signing secret → add as STRIPE_WEBHOOK_SECRET in Deployment SecretsPro tip: Webhooks do NOT work during development in Replit — the dev server has no public URL for incoming connections. Always deploy first, then register the deployed URL with Stripe to test payment flows.
Expected result: stripe package is in package.json. Secrets panel shows STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY. The webhook handler exists at POST /api/webhooks/stripe.
Build the race-condition-safe bid placement route
This is the most critical route. Multiple users might bid simultaneously in the final seconds. Wrapping the validation and insert in a SELECT FOR UPDATE transaction prevents two users from both 'winning' at the same bid amount.
1import { db } from '../db.js';2import { auctions, bids } from '../../shared/schema.js';3import { eq, sql } from 'drizzle-orm';45const MIN_BID_INCREMENT = 100; // $1.00 minimum increment6const ANTI_SNIPE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes7const ANTI_SNIPE_EXTENSION_MS = 2 * 60 * 1000; // extend by 2 minutes89export async function placeBid(req, res) {10 const auctionId = parseInt(req.params.id);11 const bidderId = req.get('X-Replit-User-Id');12 const amount = parseInt(req.body.amount); // in cents1314 if (!bidderId) return res.status(401).json({ error: 'Not authenticated' });15 if (!amount || amount <= 0) return res.status(400).json({ error: 'Invalid bid amount' });1617 // Use raw SQL transaction for SELECT FOR UPDATE (Drizzle doesn't support it natively)18 const client = await db.$client.connect();19 try {20 await client.query('BEGIN');2122 // Lock the auction row to prevent concurrent bid races23 const { rows: [auction] } = await client.query(24 'SELECT * FROM auctions WHERE id = $1 FOR UPDATE',25 [auctionId]26 );2728 if (!auction) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Auction not found' }); }29 if (auction.status !== 'active') { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction is not active' }); }30 if (new Date(auction.end_time) <= new Date()) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction has ended' }); }31 if (auction.seller_id === bidderId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Sellers cannot bid on their own auctions' }); }3233 const minBid = Math.max(auction.starting_price, auction.current_bid + MIN_BID_INCREMENT);34 if (amount < minBid) {35 await client.query('ROLLBACK');36 return res.status(400).json({ error: `Minimum bid is $${(minBid / 100).toFixed(2)}` });37 }3839 // Insert the bid40 await client.query(41 'INSERT INTO bids (auction_id, bidder_id, amount) VALUES ($1, $2, $3)',42 [auctionId, bidderId, amount]43 );4445 // Anti-sniping: extend end_time if bid is within 2 minutes of end46 const now = new Date();47 const endTime = new Date(auction.end_time);48 let newEndTime = endTime;49 if (endTime - now < ANTI_SNIPE_WINDOW_MS) {50 newEndTime = new Date(now.getTime() + ANTI_SNIPE_EXTENSION_MS);51 }5253 // Update current_bid, bid_count, and optionally end_time54 await client.query(55 'UPDATE auctions SET current_bid = $1, bid_count = bid_count + 1, end_time = $2 WHERE id = $3',56 [amount, newEndTime, auctionId]57 );5859 await client.query('COMMIT');60 res.json({ success: true, newBid: amount, newEndTime, antiSniped: newEndTime > endTime });61 } catch (err) {62 await client.query('ROLLBACK');63 console.error('Bid placement error:', err);64 res.status(500).json({ error: 'Bid placement failed' });65 } finally {66 client.release();67 }68}Pro tip: The SELECT FOR UPDATE acquires a row-level lock on the auction. Any other bid request for the same auction will wait until this transaction completes. This prevents the race condition where two simultaneous bids at the same amount both 'succeed'.
Expected result: Concurrent bid requests are serialized correctly. If two requests arrive at the same time for the same auction, only one succeeds. The other gets a 'Minimum bid is...' error because the first bid already raised current_bid.
Add the Stripe post-auction payment flow
Auctions don't charge at bid time — the winner pays after the auction ends. When the winner clicks 'Pay Now', create a Stripe Checkout Session for the final bid amount. The webhook confirms payment.
1import Stripe from 'stripe';2const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);34// POST /api/auctions/:id/pay — winner initiates payment5export async function initiatePayment(req, res) {6 const auctionId = parseInt(req.params.id);7 const userId = req.get('X-Replit-User-Id');89 const [auction] = await db.select().from(auctions).where(eq(auctions.id, auctionId));10 if (!auction || auction.status !== 'ended') {11 return res.status(400).json({ error: 'Auction is not in ended status' });12 }1314 // Verify the requesting user is the highest bidder15 const [winningBid] = await db16 .select()17 .from(bids)18 .where(eq(bids.auctionId, auctionId))19 .orderBy(sql`amount DESC`)20 .limit(1);2122 if (!winningBid || winningBid.bidderId !== userId) {23 return res.status(403).json({ error: 'Only the winning bidder can pay' });24 }2526 // Check reserve price27 if (auction.reservePrice && auction.currentBid < auction.reservePrice) {28 return res.status(400).json({ error: 'Reserve price was not met — auction is unsold' });29 }3031 const session = await stripe.checkout.sessions.create({32 payment_method_types: ['card'],33 mode: 'payment',34 line_items: [{35 price_data: {36 currency: 'usd',37 unit_amount: auction.currentBid,38 product_data: { name: auction.title, description: `Winning bid for auction #${auctionId}` },39 },40 quantity: 1,41 }],42 success_url: `${process.env.APP_URL}/auctions/${auctionId}/confirmation?session_id={CHECKOUT_SESSION_ID}`,43 cancel_url: `${process.env.APP_URL}/auctions/${auctionId}`,44 metadata: { auctionId: String(auctionId), bidderId: userId },45 });4647 res.json({ url: session.url });48}4950// POST /api/webhooks/stripe — handle payment confirmation51// MUST use express.raw({ type: 'application/json' }) — register BEFORE express.json()52export function stripeWebhook(req, res) {53 const sig = req.headers['stripe-signature'];54 let event;5556 try {57 // constructEvent is synchronous (Node.js) — NOT constructEventAsync (Deno-only)58 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);59 } catch (err) {60 return res.status(400).json({ error: `Webhook signature failed: ${err.message}` });61 }6263 if (event.type === 'checkout.session.completed') {64 const session = event.data.object;65 const { auctionId } = session.metadata;66 db.update(auctions)67 .set({ status: 'sold' })68 .where(eq(auctions.id, parseInt(auctionId)))69 .catch(console.error);70 }7172 res.json({ received: true });73}Pro tip: Register the Stripe webhook route BEFORE express.json() middleware in server/index.js. If express.json() runs first, it parses the body and Stripe's signature verification fails because it needs the raw bytes.
Expected result: The winning bidder hits POST /api/auctions/:id/pay and gets redirected to Stripe Checkout. After payment, the webhook fires and updates auction status to 'sold'.
Deploy on Reserved VM for always-on bidding
Unlike most apps, auction platforms cannot tolerate cold starts. A 15-second cold start during the last 30 seconds of a heated auction would be catastrophic. Reserved VM keeps the bid endpoint always-on.
1// Deploy steps:2// 1. Click Deploy in Replit top-right → choose Reserved VM (not Autoscale)3// 2. Select the $10/month tier for low-traffic auctions4//5// In Deployment Secrets (separate from workspace Secrets), add:6// STRIPE_SECRET_KEY=sk_test_... (or sk_live_... for production)7// STRIPE_WEBHOOK_SECRET=whsec_... (get this from Stripe Dashboard)8// APP_URL=https://your-deployed-url.replit.app9//10// Register webhook in Stripe Dashboard:11// Developers → Webhooks → Add endpoint12// URL: https://your-deployed-url.replit.app/api/webhooks/stripe13// Events to listen for: checkout.session.completed14//15// Test the full flow in Stripe test mode:16// - Create an auction (status = active, end_time = 5 minutes from now)17// - Place a bid from a second account (private browser)18// - After end_time, click Pay Now as the winner19// - Use Stripe test card: 4242 4242 4242 4242, any future expiry, any CVC20// - Check that auction status changes to 'sold' after paymentPro tip: Add a scheduled check that runs every minute using setInterval on Reserved VM. It queries for auctions where end_time < now() and status = 'active', then updates their status to 'ended'. This is the automatic auction closure mechanism.
Expected result: The deployed auction platform responds to bid requests within 100ms. Stripe webhooks are received and processed. Auctions transition automatically from 'active' to 'ended' when their end_time passes.
Complete code
1import { db } from '../db.js';2import { auctions, bids } from '../../shared/schema.js';34const MIN_INCREMENT = 100; // $1.00 minimum bid increment5const SNIPE_WINDOW = 120000; // 2 minutes in milliseconds6const SNIPE_EXTENSION = 120000; // extend by 2 minutes78export async function placeBid(req, res) {9 const auctionId = parseInt(req.params.id);10 const bidderId = req.get('X-Replit-User-Id');11 const amount = parseInt(req.body.amount);1213 if (!bidderId) return res.status(401).json({ error: 'Not authenticated' });14 if (!amount || isNaN(amount) || amount <= 0) {15 return res.status(400).json({ error: 'amount must be a positive integer in cents' });16 }1718 const client = await db.$client.connect();19 try {20 await client.query('BEGIN');21 const { rows: [auction] } = await client.query(22 'SELECT * FROM auctions WHERE id = $1 FOR UPDATE', [auctionId]23 );2425 if (!auction) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Auction not found' }); }26 if (auction.status !== 'active') { await client.query('ROLLBACK'); return res.status(400).json({ error: `Auction status is '${auction.status}', not active` }); }27 if (new Date(auction.end_time) <= new Date()) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction has ended' }); }28 if (auction.bidder_id === bidderId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Cannot bid on your own auction' }); }2930 const minBid = Math.max(auction.starting_price, (auction.current_bid || 0) + MIN_INCREMENT);31 if (amount < minBid) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Minimum bid: $${(minBid / 100).toFixed(2)}` }); }3233 await client.query('INSERT INTO bids (auction_id, bidder_id, amount) VALUES ($1, $2, $3)', [auctionId, bidderId, amount]);3435 const now = Date.now();36 const endTime = new Date(auction.end_time);37 const extended = (endTime - now) < SNIPE_WINDOW;38 const newEndTime = extended ? new Date(now + SNIPE_EXTENSION) : endTime;3940 await client.query('UPDATE auctions SET current_bid=$1, bid_count=bid_count+1, end_time=$2 WHERE id=$3', [amount, newEndTime, auctionId]);41 await client.query('COMMIT');4243 res.json({ success: true, amount, newEndTime, extended });44 } catch (err) {45 await client.query('ROLLBACK');46 res.status(500).json({ error: 'Bid failed' });47 } finally {48 client.release();49 }50}Customization ideas
Proxy bidding (automatic bidding)
Let users set a maximum bid. When someone bids, the system automatically counters up to the proxy maximum. Store max_proxy_bid per user per auction and apply it in the bid placement transaction.
Buy It Now price
Add a buy_now_price column to auctions. If a buyer pays the buy-it-now price directly (bypassing the auction), end the auction immediately and mark it as sold. Create a Stripe Checkout Session at the buy-it-now price.
Seller analytics dashboard
Add a /api/me/seller/stats route that returns total auctions created, auctions sold vs unsold, total revenue, average bid count per auction, and most popular category.
Common pitfalls
Pitfall: Deploying on Autoscale instead of Reserved VM
How to avoid: Deploy auction platforms on Reserved VM. The always-on server is essential for the integrity of the bidding process. The $10/month cost is negligible for a real auction platform.
Pitfall: Not using SELECT FOR UPDATE on bid placement
How to avoid: Use a PostgreSQL transaction with SELECT ... FOR UPDATE on the auction row. This serializes concurrent bid requests and ensures only one can read-validate-insert at a time.
Pitfall: Registering the Stripe webhook route after express.json()
How to avoid: In server/index.js, register app.post('/api/webhooks/stripe', express.raw({type:'application/json'}), stripeWebhook) BEFORE app.use(express.json()).
Best practices
- Store all prices as integers in cents — never store $10.50 as a float, always as 1050.
- Wrap bid placement in a BEGIN / SELECT FOR UPDATE / INSERT / COMMIT transaction to prevent race conditions.
- Deploy on Reserved VM for auction platforms — cold starts during live bidding are unacceptable.
- Implement anti-sniping in the same transaction as the bid insert — never as a separate async operation.
- Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET to Deployment Secrets separately from workspace Secrets.
- Use constructEvent() (synchronous) not constructEventAsync() for Stripe webhook verification in Node.js.
- Run an auction-closure setInterval every 60 seconds to transition expired active auctions to 'ended' status.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an auction platform with Express and PostgreSQL using Drizzle ORM. I need to implement a bid placement endpoint that prevents race conditions when multiple users bid simultaneously. Help me write the transaction logic using SELECT FOR UPDATE to lock the auction row, validate the new bid amount is greater than current_bid plus the minimum increment, insert the bid, update current_bid and bid_count atomically, and implement anti-sniping by extending end_time by 2 minutes if the bid arrives within 2 minutes of end_time.
Add a bid notification system to the auction platform. When a user is outbid (someone places a higher bid on an auction they previously bid on), call the Twilio API to send them an SMS alert: 'You've been outbid on [title]. Current bid: $X. Bid now: [link]'. Store TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER in Replit Secrets. Trigger the notification in the bid placement transaction after updating current_bid.
Frequently asked questions
How do I handle the case where nobody bids on an auction?
In the auction closure setInterval, if an auction's end_time has passed and bid_count is 0 (or current_bid is less than reserve_price), set status to 'unsold'. The seller's dashboard should show unsold auctions with an option to relist them.
What is the reserve price and how does it work?
The reserve price is the minimum amount the seller is willing to accept. It's hidden from bidders. When the auction ends, if current_bid is less than reserve_price, the auction is marked 'unsold' and no payment is collected. Show a 'Reserve not met' label in the UI when this happens.
Can I accept payments at bid time instead of after the auction?
Yes, but it's more complex. You'd collect a payment authorization (not capture) at bid time with Stripe PaymentIntents in manual capture mode. When the bidder is outbid, release the authorization. When the auction ends, capture only the winner's authorization. This requires careful handling of multiple active authorizations.
Why use polling instead of WebSockets for real-time bid updates?
WebSockets require persistent connections, which work well on Reserved VM but are tricky to implement correctly with reconnection logic. Polling every 5 seconds is simpler, sufficiently responsive for most auctions, and works on any deployment type. Reduce the polling interval to 2 seconds in the final 5 minutes for a more exciting experience.
Do I need Replit Core for this build?
Yes. Replit Auth (used for bidder/seller authentication) requires Replit Core or higher. The built-in PostgreSQL is also included in Core. Additionally, Reserved VM deployment (needed for always-on bidding) requires Core.
Can RapidDev help me build a custom auction platform?
Yes. RapidDev has built 600+ apps including auction and marketplace platforms with advanced features like proxy bidding, Stripe Connect payouts to sellers, and fraud detection. Contact us for a free consultation.
How do I go from Stripe test mode to live mode?
In Stripe Dashboard, toggle from Test to Live mode. Go to Stripe Marketplace, install the Replit Integrated Payments app to activate your account. Then swap STRIPE_SECRET_KEY (sk_test_ → sk_live_) and STRIPE_WEBHOOK_SECRET in your Deployment Secrets. Register a new live webhook endpoint in Stripe Dashboard pointing to your deployed URL.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation