# How to Build a Donation System with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a GoFundMe-style donation platform in Replit in 1-2 hours. Campaigns show live progress bars, donors can give one-time or monthly, and Stripe handles all payments securely. Features anonymous giving, a public donor wall with messages, and webhook-driven real-time totals. Uses Express, PostgreSQL, Drizzle ORM, and Stripe.

## Before you start

- A Replit account (free tier is sufficient)
- A Stripe account (free at stripe.com — start in test mode)
- Basic understanding of what a payment form and a database table are (no coding needed)
- Stripe test card: 4242 4242 4242 4242, any future expiry, any CVC

## Step-by-step guide

### 1. Set up the project and run the /stripe command

Create a new Replit and use Agent to scaffold the backend schema, then run /stripe to auto-provision Stripe sandbox credentials and generate the payment boilerplate. This is the fastest path to working Stripe integration.

```
// Prompt to type into Replit Agent:
// Build a donation system with Express and PostgreSQL using Drizzle ORM.
// Create these tables in shared/schema.ts:
// - campaigns: id serial pk, creator_id text, title text, description text,
//   goal_amount integer (cents), current_amount integer default 0,
//   image_url text, status text default 'active' (draft/active/completed/cancelled),
//   end_date timestamp, created_at timestamp
// - donors: id serial pk, user_id text nullable, email text not null,
//   name text, is_anonymous boolean default false,
//   stripe_customer_id text, total_donated integer default 0, created_at timestamp
// - donations: id serial pk, campaign_id integer references campaigns,
//   donor_id integer references donors, amount integer (cents),
//   is_recurring boolean default false,
//   stripe_payment_intent_id text, stripe_subscription_id text,
//   message text, status text default 'pending' (pending/completed/failed/refunded),
//   created_at timestamp
// - recurring_donations: id serial pk, donor_id integer references donors,
//   campaign_id integer references campaigns, amount integer,
//   stripe_subscription_id text unique, interval text default 'monthly',
//   status text default 'active' (active/cancelled/past_due), created_at timestamp
// - webhook_events: id serial pk, stripe_event_id text unique, event_type text, processed_at timestamp
// Then type /stripe to set up Stripe integration.
```

> Pro tip: The /stripe command in Replit automatically sets STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY as encrypted Secrets. You'll add STRIPE_WEBHOOK_SECRET manually after setting up the webhook endpoint.

**Expected result:** Replit creates the schema and the /stripe command generates server/routes/payments.js with Checkout session code. Your STRIPE_SECRET_KEY is visible in the Secrets panel (lock icon).

### 2. Build the campaign and donation routes

The campaign routes are standard CRUD. The critical route is POST /api/donate — it builds a Stripe Checkout Session server-side with the final amount, ensuring the client can never manipulate the donation amount.

```
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
const { db } = require('../db');
const { campaigns, donors, donations } = require('../../shared/schema');
const { eq } = require('drizzle-orm');

router.post('/api/donate', async (req, res) => {
  const { campaignId, amount, isRecurring, message, name, email, isAnonymous } = req.body;

  if (!amount || amount < 100) return res.status(400).json({ error: 'Minimum donation is $1.00' });

  const [campaign] = await db.select().from(campaigns).where(eq(campaigns.id, Number(campaignId)));
  if (!campaign || campaign.status !== 'active') {
    return res.status(404).json({ error: 'Campaign not found or inactive' });
  }

  // Find or create donor record
  let [donor] = await db.select().from(donors).where(eq(donors.email, email));
  if (!donor) {
    [donor] = await db.insert(donors).values({ email, name, isAnonymous, userId: req.user?.id }).returning();
  }

  const mode = isRecurring ? 'subscription' : 'payment';
  const lineItems = [{
    price_data: {
      currency: 'usd',
      product_data: { name: campaign.title, description: message || undefined },
      unit_amount: Number(amount),
      ...(isRecurring ? { recurring: { interval: 'month' } } : {})
    },
    quantity: 1
  }];

  const session = await stripe.checkout.sessions.create({
    mode,
    line_items: lineItems,
    customer_email: email,
    metadata: { campaignId: String(campaign.id), donorId: String(donor.id), message: message || '' },
    success_url: `${process.env.APP_URL}/campaigns/${campaign.id}?donated=true`,
    cancel_url: `${process.env.APP_URL}/campaigns/${campaign.id}`
  });

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

> Pro tip: Store the donation message in Stripe's metadata field so the webhook handler can save it to the database without needing a second lookup. Stripe metadata values are accessible in all webhook events related to that Checkout Session.

**Expected result:** POST /api/donate returns a Stripe Checkout URL. In test mode, completing payment with card 4242 4242 4242 4242 triggers the success redirect. The webhook fires next (after deployment).

### 3. Build the webhook handler

The webhook is where donations become real. It verifies the Stripe signature using constructEvent() (the sync version, not async), then atomically updates the campaign total and logs the donation. An idempotency check prevents double-counting on retries.

```
// IMPORTANT: This route must use express.raw() BEFORE express.json()
// Add this BEFORE app.use(express.json()) in server/index.js:
// app.use('/api/webhooks/stripe', express.raw({ type: 'application/json' }));

router.post('/api/webhooks/stripe', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    // Use constructEvent (SYNC) — not constructEventAsync
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.error('Webhook signature failed:', err.message);
    return res.status(400).json({ error: 'Webhook signature verification failed' });
  }

  // Idempotency check — skip if already processed
  const existing = await db.select().from(webhookEvents)
    .where(eq(webhookEvents.stripeEventId, event.id));
  if (existing.length > 0) return res.json({ received: true, status: 'duplicate' });

  // Log the event first
  await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const { campaignId, donorId, message } = session.metadata;
    const amount = session.amount_total;

    // Insert donation record
    await db.insert(donations).values({
      campaignId: Number(campaignId),
      donorId: Number(donorId),
      amount,
      isRecurring: session.mode === 'subscription',
      stripePaymentIntentId: session.payment_intent,
      stripeSubscriptionId: session.subscription,
      message,
      status: 'completed'
    });

    // Atomically increment campaign total
    await db.execute(
      require('drizzle-orm').sql`UPDATE campaigns SET current_amount = current_amount + ${amount} WHERE id = ${Number(campaignId)}`
    );

    // Update donor total
    await db.execute(
      require('drizzle-orm').sql`UPDATE donors SET total_donated = total_donated + ${amount} WHERE id = ${Number(donorId)}`
    );
  }

  if (event.type === 'invoice.paid') {
    const invoice = event.data.object;
    if (invoice.subscription) {
      const sub = await stripe.subscriptions.retrieve(invoice.subscription);
      const { campaignId, donorId } = sub.metadata;
      if (campaignId && donorId) {
        const amount = invoice.amount_paid;
        await db.insert(donations).values({
          campaignId: Number(campaignId), donorId: Number(donorId), amount,
          isRecurring: true, stripeSubscriptionId: invoice.subscription, status: 'completed'
        });
        await db.execute(
          require('drizzle-orm').sql`UPDATE campaigns SET current_amount = current_amount + ${amount} WHERE id = ${Number(campaignId)}`
        );
      }
    }
  }

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

> Pro tip: Webhooks only work after deployment — they require a public URL. Deploy first on Autoscale, then copy your deployment URL and register it in Stripe Dashboard under Developers → Webhooks → Add endpoint. Select events: checkout.session.completed and invoice.paid.

**Expected result:** After deployment and webhook registration, completing a test donation triggers the webhook. Check the webhook_events table in Drizzle Studio to confirm it was logged and processed.

### 4. Build the campaign page and donor wall

The donor wall is a key social proof feature. It shows recent donors with their names (or 'Anonymous') and messages, building trust and encouraging others to give. The progress bar visually shows campaign momentum.

```
// Prompt to type into Replit Agent:
// Build these React components:
//
// 1. CampaignPage at client/src/pages/CampaignPage.jsx:
//    - Fetch GET /api/campaigns/:id on load
//    - Show: title, description, image, creator name
//    - Progress section:
//      * Current amount in dollars (current_amount / 100)
//      * Goal amount in dollars
//      * Progress bar: width = (current_amount / goal_amount * 100)%
//      * Donor count from GET /api/campaigns/:id/donors?count=true
//    - Donate section:
//      * Preset amount buttons: $10, $25, $50, $100, Other
//      * Custom amount input (shown when 'Other' selected)
//      * Monthly/One-time toggle
//      * Name input, Email input
//      * Anonymous checkbox
//      * Message textarea (optional)
//      * Donate button → POST /api/donate → redirect to session.url
//
// 2. DonorWall component:
//    - Fetch GET /api/campaigns/:id/donors (shows approved completed donations)
//    - Show last 20 donors in a scrollable list
//    - Each donor: avatar initials circle, name (or 'Anonymous'), amount, message, time ago
//    - 'Be the first to donate' empty state
//
// Add route: GET /api/campaigns/:id/donors
//   Join donations + donors WHERE campaign_id=:id AND status='completed'
//   Return is_anonymous ? {name: 'Anonymous', ...} : actual donor info
//   Order by created_at DESC, limit 20
```

**Expected result:** The campaign page shows a live progress bar and donor wall. Completing a test donation adds the donor to the wall after the webhook fires and updates the progress bar on the next page load.

### 5. Add STRIPE_WEBHOOK_SECRET and deploy

After deploying and registering the webhook endpoint in Stripe Dashboard, copy the webhook signing secret and add it to Replit Secrets. This is the last step before the end-to-end flow is complete.

```
// Step-by-step deployment checklist:
//
// 1. Open Replit Secrets (lock icon in sidebar) and verify these exist:
//    - STRIPE_SECRET_KEY (set by /stripe command)
//    - STRIPE_PUBLISHABLE_KEY (set by /stripe command)
//    - APP_URL — your Replit deployment URL (add this after first deploy)
//    - SESSION_SECRET — any 32-character random string
//
// 2. Deploy to Autoscale: click Deploy → Autoscale in the top-right menu
//    Your app URL will be: https://your-repl-name.your-username.repl.co
//
// 3. Go to Stripe Dashboard → Developers → Webhooks → Add endpoint
//    Endpoint URL: https://your-deployment-url.repl.co/api/webhooks/stripe
//    Events: checkout.session.completed, invoice.paid
//    Click Add endpoint
//
// 4. Copy the Signing secret shown on the webhook details page
//    Add it to Replit Secrets as: STRIPE_WEBHOOK_SECRET
//
// 5. Test: visit your deployment URL, go to a campaign, donate $1 with test card
//    4242 4242 4242 4242, any future expiry, any CVC
//    Check Stripe Dashboard → Webhooks to see the event delivered successfully
```

> Pro tip: Stripe shows webhook delivery attempts and responses in real time under Developers → Webhooks → your endpoint → Recent deliveries. If you see a 400 error, the most common cause is express.json() running before express.raw() on the webhook route — check your middleware order.

**Expected result:** End-to-end test donation completes, webhook fires, donation appears in the donor wall, and campaign total increments. Check Drizzle Studio to see the donation and webhook_events rows.

## Complete code example

File: `server/routes/webhook.js`

```javascript
const { Router } = require('express');
const Stripe = require('stripe');
const { db } = require('../db');
const { sql, eq } = require('drizzle-orm');
const { donations, campaigns, donors, webhookEvents } = require('../../shared/schema');

const router = Router();
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);

// IMPORTANT: register with express.raw BEFORE express.json in server/index.js
router.post('/api/webhooks/stripe', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    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 });
  }

  const already = await db.select().from(webhookEvents)
    .where(eq(webhookEvents.stripeEventId, event.id));
  if (already.length > 0) return res.json({ received: true, status: 'duplicate' });

  await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });

  try {
    if (event.type === 'checkout.session.completed') {
      const session = event.data.object;
      const { campaignId, donorId, message } = session.metadata;
      const amount = session.amount_total;

      await db.insert(donations).values({
        campaignId: Number(campaignId), donorId: Number(donorId), amount,
        isRecurring: session.mode === 'subscription',
        stripePaymentIntentId: session.payment_intent || null,
        stripeSubscriptionId: session.subscription || null,
        message: message || null, status: 'completed'
      });

      await db.execute(sql`UPDATE campaigns SET current_amount = current_amount + ${amount} WHERE id = ${Number(campaignId)}`);
      await db.execute(sql`UPDATE donors SET total_donated = total_donated + ${amount} WHERE id = ${Number(donorId)}`);
    }

    if (event.type === 'invoice.paid' && event.data.object.subscription) {
      const invoice = event.data.object;
      const sub = await stripe.subscriptions.retrieve(invoice.subscription);
      const { campaignId, donorId } = sub.metadata || {};
      if (campaignId && donorId) {
        await db.insert(donations).values({
          campaignId: Number(campaignId), donorId: Number(donorId),
          amount: invoice.amount_paid, isRecurring: true,
          stripeSubscriptionId: invoice.subscription, status: 'completed'
        });
        await db.execute(sql`UPDATE campaigns SET current_amount = current_amount + ${invoice.amount_paid} WHERE id = ${Number(campaignId)}`);
      }
    }
  } catch (err) {
    console.error('Webhook processing error:', err);
  }

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

module.exports = router;
```

## Common mistakes

- **Using express.json() middleware before express.raw() on the webhook route** — Stripe's constructEvent() requires the raw request body as a Buffer. If express.json() runs first, it parses the body into a JavaScript object and signature verification always fails with a 400 error. Fix: Register app.use('/api/webhooks/stripe', express.raw({ type: 'application/json' })) BEFORE app.use(express.json()) in server/index.js. The order of middleware registration is critical.
- **Testing webhooks before deploying** — Replit's development environment has no public incoming URL. Stripe cannot reach your Express server to fire webhook events during local development. Fix: Deploy first using Autoscale, then register the deployed URL in Stripe Dashboard. Use Stripe's test mode for all testing — real charges never occur with test API keys.
- **Not checking for duplicate webhook events before processing** — Stripe retries webhook delivery if your server returns anything other than a 2xx response, or if there's a network timeout. Processing the same checkout.session.completed twice doubles the donation amount. Fix: Check the webhook_events table for the event.id before processing. If found, return 200 immediately. This idempotency pattern is shown in the webhook route above.
- **Updating campaign totals by summing all donations on every webhook** — SELECT SUM(amount) on every donation is slow as campaigns grow and risks race conditions under concurrent donations. Fix: Use an atomic UPDATE campaigns SET current_amount = current_amount + :amount WHERE id = :id. This is a single operation that cannot race and runs in microseconds regardless of donation history size.

## Best practices

- Always set the raw body middleware for the Stripe webhook route before the JSON middleware — this is the most common Stripe integration error.
- Use Stripe test mode throughout development. Your STRIPE_SECRET_KEY from /stripe starts with 'sk_test_' — keep it that way until you're ready for live donations.
- Store STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Replit Secrets (lock icon), and mirror them in Deployment Secrets before going live.
- Check for idempotency on every webhook event using the webhook_events table — Stripe guarantees at-least-once delivery, not exactly-once.
- Validate minimum donation amounts server-side (minimum $1.00 = 100 cents) — never trust client-provided amounts for the Checkout Session.
- Use Drizzle Studio to monitor the donations table during testing — you can see pending vs completed donations and debug webhook processing in real time.

## Frequently asked questions

### How do I switch from Stripe test mode to live mode?

In Stripe Dashboard, toggle from Test to Live mode. Copy your live secret key (starts with sk_live_) and update the STRIPE_SECRET_KEY secret in Replit. Register a new webhook endpoint pointing to the same URL and copy the new signing secret to STRIPE_WEBHOOK_SECRET. Never mix test and live keys.

### Can donors give without creating an account?

Yes. The donate route only requires name and email — no Replit Auth login needed for donors. Replit Auth is used for campaign creators who need to manage their campaigns. Donors are identified by their email address in the donors table.

### How do recurring monthly donations get cancelled?

Add a POST /api/donors/me/cancel-recurring route that calls stripe.subscriptions.cancel(subscription_id) using the stripe_subscription_id stored in the recurring_donations table. Update the status to 'cancelled'. Stripe fires a customer.subscription.deleted webhook you can use to confirm the cancellation.

### Do I need a paid Replit plan for this?

No. The free Replit plan supports Autoscale deployment and built-in PostgreSQL. The /stripe command works on free plans. Your only costs are Stripe's processing fees: 2.9% + 30¢ per successful transaction.

### Why do webhooks only work after deployment?

Replit's development environment runs in a WebContainer that does not accept incoming HTTP connections from the internet. Stripe needs a public HTTPS URL to deliver webhook events. Deploy your app to Autoscale first, then register the deployment URL in Stripe Dashboard.

### How do I issue a refund?

Add a POST /api/donations/:id/refund route that retrieves the stripe_payment_intent_id from the donation record and calls stripe.refunds.create({ payment_intent: id }). Update the donation status to 'refunded'. Also decrement the campaign's current_amount to keep the progress bar accurate.

### Can RapidDev help build a custom fundraising platform for my organization?

Yes. RapidDev has built 600+ apps and can add features like fundraiser pages, donation matching, peer-to-peer campaigns, and charity tax receipt generation. Book a free consultation at rapidevelopers.com.

---

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