# How to Build an Auction Platform with Replit

- Tool: How to Build with Replit
- Difficulty: Advanced
- Compatibility: Replit Core or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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)

## Step-by-step guide

### 1. 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.

```
// Prompt to type into Replit Agent:
// Build a Node.js Express auction platform with Replit Auth and built-in PostgreSQL using Drizzle ORM.
// Schema in shared/schema.ts:
// * sellers: id serial pk, user_id text not null unique, display_name text not null,
//   stripe_customer_id text, verified boolean default false, created_at timestamp default now()
// * auctions: id serial pk, seller_id integer references sellers(id) not null,
//   title text not null, description text, images jsonb, starting_price integer not null,
//   reserve_price integer, current_bid integer default 0, bid_count integer default 0,
//   status text default 'draft', start_time timestamp not null, end_time timestamp not null,
//   category text, created_at timestamp default now()
// * bids: id serial pk, auction_id integer references auctions(id) not null,
//   bidder_id text not null, amount integer not null, created_at timestamp default now()
// * watchlist: id serial pk, user_id text not null, auction_id integer references auctions(id) not null,
//   unique constraint on (user_id, auction_id)
// * webhook_events: id serial pk, stripe_event_id text unique not null, event_type text,
//   processed_at timestamp default now()
// Routes: POST /api/auctions, GET /api/auctions, GET /api/auctions/:id,
// POST /api/auctions/:id/bids, POST /api/auctions/:id/watch,
// POST /api/auctions/:id/pay, POST /api/webhooks/stripe
// React frontend with auction card grid, countdown timers, bid history table, bid input form
```

> Pro 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.

### 2. 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.

```
// In the Replit Agent chat or Shell, type: /stripe
// This automatically:
// 1. Installs the stripe npm package
// 2. Sets up STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY in your workspace
// 3. Creates a basic webhook handler at POST /api/webhooks/stripe
// 4. Adds stripe.webhooks.constructEvent() verification
//
// IMPORTANT: After running /stripe, open the Secrets panel (lock icon 🔒)
// and verify these are set:
// STRIPE_SECRET_KEY=sk_test_...
// STRIPE_PUBLISHABLE_KEY=pk_test_...
// STRIPE_WEBHOOK_SECRET=whsec_...  (you'll get this from Stripe Dashboard after deploying)
//
// The webhook secret is only available after deployment.
// Deploy first, then register your deployed URL in Stripe Dashboard:
// Stripe Dashboard → Developers → Webhooks → Add endpoint
// URL: https://your-repl.replit.app/api/webhooks/stripe
// Events: checkout.session.completed
// Copy the signing secret → add as STRIPE_WEBHOOK_SECRET in Deployment Secrets
```

> Pro 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.

### 3. 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.

```
import { db } from '../db.js';
import { auctions, bids } from '../../shared/schema.js';
import { eq, sql } from 'drizzle-orm';

const MIN_BID_INCREMENT = 100; // $1.00 minimum increment
const ANTI_SNIPE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes
const ANTI_SNIPE_EXTENSION_MS = 2 * 60 * 1000; // extend by 2 minutes

export async function placeBid(req, res) {
  const auctionId = parseInt(req.params.id);
  const bidderId = req.get('X-Replit-User-Id');
  const amount = parseInt(req.body.amount); // in cents

  if (!bidderId) return res.status(401).json({ error: 'Not authenticated' });
  if (!amount || amount <= 0) return res.status(400).json({ error: 'Invalid bid amount' });

  // Use raw SQL transaction for SELECT FOR UPDATE (Drizzle doesn't support it natively)
  const client = await db.$client.connect();
  try {
    await client.query('BEGIN');

    // Lock the auction row to prevent concurrent bid races
    const { rows: [auction] } = await client.query(
      'SELECT * FROM auctions WHERE id = $1 FOR UPDATE',
      [auctionId]
    );

    if (!auction) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Auction not found' }); }
    if (auction.status !== 'active') { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction is not active' }); }
    if (new Date(auction.end_time) <= new Date()) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction has ended' }); }
    if (auction.seller_id === bidderId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Sellers cannot bid on their own auctions' }); }

    const minBid = Math.max(auction.starting_price, auction.current_bid + MIN_BID_INCREMENT);
    if (amount < minBid) {
      await client.query('ROLLBACK');
      return res.status(400).json({ error: `Minimum bid is $${(minBid / 100).toFixed(2)}` });
    }

    // Insert the bid
    await client.query(
      'INSERT INTO bids (auction_id, bidder_id, amount) VALUES ($1, $2, $3)',
      [auctionId, bidderId, amount]
    );

    // Anti-sniping: extend end_time if bid is within 2 minutes of end
    const now = new Date();
    const endTime = new Date(auction.end_time);
    let newEndTime = endTime;
    if (endTime - now < ANTI_SNIPE_WINDOW_MS) {
      newEndTime = new Date(now.getTime() + ANTI_SNIPE_EXTENSION_MS);
    }

    // Update current_bid, bid_count, and optionally end_time
    await client.query(
      'UPDATE auctions SET current_bid = $1, bid_count = bid_count + 1, end_time = $2 WHERE id = $3',
      [amount, newEndTime, auctionId]
    );

    await client.query('COMMIT');
    res.json({ success: true, newBid: amount, newEndTime, antiSniped: newEndTime > endTime });
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Bid placement error:', err);
    res.status(500).json({ error: 'Bid placement failed' });
  } finally {
    client.release();
  }
}
```

> 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.

### 4. 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.

```
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// POST /api/auctions/:id/pay — winner initiates payment
export async function initiatePayment(req, res) {
  const auctionId = parseInt(req.params.id);
  const userId = req.get('X-Replit-User-Id');

  const [auction] = await db.select().from(auctions).where(eq(auctions.id, auctionId));
  if (!auction || auction.status !== 'ended') {
    return res.status(400).json({ error: 'Auction is not in ended status' });
  }

  // Verify the requesting user is the highest bidder
  const [winningBid] = await db
    .select()
    .from(bids)
    .where(eq(bids.auctionId, auctionId))
    .orderBy(sql`amount DESC`)
    .limit(1);

  if (!winningBid || winningBid.bidderId !== userId) {
    return res.status(403).json({ error: 'Only the winning bidder can pay' });
  }

  // Check reserve price
  if (auction.reservePrice && auction.currentBid < auction.reservePrice) {
    return res.status(400).json({ error: 'Reserve price was not met — auction is unsold' });
  }

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    mode: 'payment',
    line_items: [{
      price_data: {
        currency: 'usd',
        unit_amount: auction.currentBid,
        product_data: { name: auction.title, description: `Winning bid for auction #${auctionId}` },
      },
      quantity: 1,
    }],
    success_url: `${process.env.APP_URL}/auctions/${auctionId}/confirmation?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/auctions/${auctionId}`,
    metadata: { auctionId: String(auctionId), bidderId: userId },
  });

  res.json({ url: session.url });
}

// POST /api/webhooks/stripe — handle payment confirmation
// MUST use express.raw({ type: 'application/json' }) — register BEFORE express.json()
export function stripeWebhook(req, res) {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    // constructEvent is synchronous (Node.js) — NOT constructEventAsync (Deno-only)
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).json({ error: `Webhook signature failed: ${err.message}` });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const { auctionId } = session.metadata;
    db.update(auctions)
      .set({ status: 'sold' })
      .where(eq(auctions.id, parseInt(auctionId)))
      .catch(console.error);
  }

  res.json({ received: true });
}
```

> 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'.

### 5. 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.

```
// Deploy steps:
// 1. Click Deploy in Replit top-right → choose Reserved VM (not Autoscale)
// 2. Select the $10/month tier for low-traffic auctions
//
// In Deployment Secrets (separate from workspace Secrets), add:
// STRIPE_SECRET_KEY=sk_test_...  (or sk_live_... for production)
// STRIPE_WEBHOOK_SECRET=whsec_... (get this from Stripe Dashboard)
// APP_URL=https://your-deployed-url.replit.app
//
// Register webhook in Stripe Dashboard:
// Developers → Webhooks → Add endpoint
// URL: https://your-deployed-url.replit.app/api/webhooks/stripe
// Events to listen for: checkout.session.completed
//
// Test the full flow in Stripe test mode:
// - Create an auction (status = active, end_time = 5 minutes from now)
// - Place a bid from a second account (private browser)
// - After end_time, click Pay Now as the winner
// - Use Stripe test card: 4242 4242 4242 4242, any future expiry, any CVC
// - Check that auction status changes to 'sold' after payment
```

> Pro 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 example

File: `server/routes/bids.js`

```javascript
import { db } from '../db.js';
import { auctions, bids } from '../../shared/schema.js';

const MIN_INCREMENT = 100; // $1.00 minimum bid increment
const SNIPE_WINDOW = 120000; // 2 minutes in milliseconds
const SNIPE_EXTENSION = 120000; // extend by 2 minutes

export async function placeBid(req, res) {
  const auctionId = parseInt(req.params.id);
  const bidderId = req.get('X-Replit-User-Id');
  const amount = parseInt(req.body.amount);

  if (!bidderId) return res.status(401).json({ error: 'Not authenticated' });
  if (!amount || isNaN(amount) || amount <= 0) {
    return res.status(400).json({ error: 'amount must be a positive integer in cents' });
  }

  const client = await db.$client.connect();
  try {
    await client.query('BEGIN');
    const { rows: [auction] } = await client.query(
      'SELECT * FROM auctions WHERE id = $1 FOR UPDATE', [auctionId]
    );

    if (!auction) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Auction not found' }); }
    if (auction.status !== 'active') { await client.query('ROLLBACK'); return res.status(400).json({ error: `Auction status is '${auction.status}', not active` }); }
    if (new Date(auction.end_time) <= new Date()) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction has ended' }); }
    if (auction.bidder_id === bidderId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Cannot bid on your own auction' }); }

    const minBid = Math.max(auction.starting_price, (auction.current_bid || 0) + MIN_INCREMENT);
    if (amount < minBid) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Minimum bid: $${(minBid / 100).toFixed(2)}` }); }

    await client.query('INSERT INTO bids (auction_id, bidder_id, amount) VALUES ($1, $2, $3)', [auctionId, bidderId, amount]);

    const now = Date.now();
    const endTime = new Date(auction.end_time);
    const extended = (endTime - now) < SNIPE_WINDOW;
    const newEndTime = extended ? new Date(now + SNIPE_EXTENSION) : endTime;

    await client.query('UPDATE auctions SET current_bid=$1, bid_count=bid_count+1, end_time=$2 WHERE id=$3', [amount, newEndTime, auctionId]);
    await client.query('COMMIT');

    res.json({ success: true, amount, newEndTime, extended });
  } catch (err) {
    await client.query('ROLLBACK');
    res.status(500).json({ error: 'Bid failed' });
  } finally {
    client.release();
  }
}
```

## Common mistakes

- **Deploying on Autoscale instead of Reserved VM** — Autoscale scales to zero after idle periods, causing 15-30 second cold starts. A cold start during the final seconds of a competitive auction causes bids to time out. Fix: 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.
- **Not using SELECT FOR UPDATE on bid placement** — Without locking, two simultaneous bids at $10.50 can both see current_bid = $10.00, both pass validation, and both insert — creating two 'winning' bids at the same amount. Fix: 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.
- **Registering the Stripe webhook route after express.json()** — express.json() parses the request body as JSON. Stripe's constructEvent() needs the raw Buffer to verify the HMAC signature. If the body is already parsed, verification always fails. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/auction-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/auction-platform
