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.
What you're building
Every nonprofit, creator, and community organizer eventually needs a way to accept donations online. The alternatives — GoFundMe, Donorbox, Fundly — take 3-8% platform fees on top of Stripe's processing fee. Building your own donation system with Stripe directly cuts the overhead to just Stripe's 2.9% + 30¢ per charge.
Replit's /stripe command auto-provisions a Stripe sandbox environment and generates the payment routes. The core donation flow is simple: a donor selects an amount, optionally checks 'Make it monthly', and gets redirected to Stripe Checkout. After payment, Stripe fires a webhook to your Express app. The webhook handler verifies the signature using constructEvent() (the sync version), inserts the donation record, and atomically increments the campaign total using a single UPDATE statement.
The architecture uses Replit's built-in PostgreSQL for campaigns, donors, and donations. Recurring donations create Stripe subscriptions, and the invoice.paid webhook fires monthly to log each renewal. Deploy on Autoscale — donation campaigns get spiky traffic when shared on social media, and the cold start is fast enough for this use case.
Final result
A fully functional donation platform with campaign pages, Stripe-powered one-time and recurring giving, a donor wall, and webhook-driven progress tracking — ready to accept real donations after switching Stripe to live mode.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Prompt to type into Replit Agent:2// Build a donation system with Express and PostgreSQL using Drizzle ORM.3// Create these tables in shared/schema.ts:4// - campaigns: id serial pk, creator_id text, title text, description text,5// goal_amount integer (cents), current_amount integer default 0,6// image_url text, status text default 'active' (draft/active/completed/cancelled),7// end_date timestamp, created_at timestamp8// - donors: id serial pk, user_id text nullable, email text not null,9// name text, is_anonymous boolean default false,10// stripe_customer_id text, total_donated integer default 0, created_at timestamp11// - donations: id serial pk, campaign_id integer references campaigns,12// donor_id integer references donors, amount integer (cents),13// is_recurring boolean default false,14// stripe_payment_intent_id text, stripe_subscription_id text,15// message text, status text default 'pending' (pending/completed/failed/refunded),16// created_at timestamp17// - recurring_donations: id serial pk, donor_id integer references donors,18// campaign_id integer references campaigns, amount integer,19// stripe_subscription_id text unique, interval text default 'monthly',20// status text default 'active' (active/cancelled/past_due), created_at timestamp21// - webhook_events: id serial pk, stripe_event_id text unique, event_type text, processed_at timestamp22// 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).
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.
1const Stripe = require('stripe');2const stripe = Stripe(process.env.STRIPE_SECRET_KEY);3const { db } = require('../db');4const { campaigns, donors, donations } = require('../../shared/schema');5const { eq } = require('drizzle-orm');67router.post('/api/donate', async (req, res) => {8 const { campaignId, amount, isRecurring, message, name, email, isAnonymous } = req.body;910 if (!amount || amount < 100) return res.status(400).json({ error: 'Minimum donation is $1.00' });1112 const [campaign] = await db.select().from(campaigns).where(eq(campaigns.id, Number(campaignId)));13 if (!campaign || campaign.status !== 'active') {14 return res.status(404).json({ error: 'Campaign not found or inactive' });15 }1617 // Find or create donor record18 let [donor] = await db.select().from(donors).where(eq(donors.email, email));19 if (!donor) {20 [donor] = await db.insert(donors).values({ email, name, isAnonymous, userId: req.user?.id }).returning();21 }2223 const mode = isRecurring ? 'subscription' : 'payment';24 const lineItems = [{25 price_data: {26 currency: 'usd',27 product_data: { name: campaign.title, description: message || undefined },28 unit_amount: Number(amount),29 ...(isRecurring ? { recurring: { interval: 'month' } } : {})30 },31 quantity: 132 }];3334 const session = await stripe.checkout.sessions.create({35 mode,36 line_items: lineItems,37 customer_email: email,38 metadata: { campaignId: String(campaign.id), donorId: String(donor.id), message: message || '' },39 success_url: `${process.env.APP_URL}/campaigns/${campaign.id}?donated=true`,40 cancel_url: `${process.env.APP_URL}/campaigns/${campaign.id}`41 });4243 res.json({ url: session.url });44});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).
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.
1// IMPORTANT: This route must use express.raw() BEFORE express.json()2// Add this BEFORE app.use(express.json()) in server/index.js:3// app.use('/api/webhooks/stripe', express.raw({ type: 'application/json' }));45router.post('/api/webhooks/stripe', async (req, res) => {6 const sig = req.headers['stripe-signature'];7 let event;89 try {10 // Use constructEvent (SYNC) — not constructEventAsync11 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);12 } catch (err) {13 console.error('Webhook signature failed:', err.message);14 return res.status(400).json({ error: 'Webhook signature verification failed' });15 }1617 // Idempotency check — skip if already processed18 const existing = await db.select().from(webhookEvents)19 .where(eq(webhookEvents.stripeEventId, event.id));20 if (existing.length > 0) return res.json({ received: true, status: 'duplicate' });2122 // Log the event first23 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });2425 if (event.type === 'checkout.session.completed') {26 const session = event.data.object;27 const { campaignId, donorId, message } = session.metadata;28 const amount = session.amount_total;2930 // Insert donation record31 await db.insert(donations).values({32 campaignId: Number(campaignId),33 donorId: Number(donorId),34 amount,35 isRecurring: session.mode === 'subscription',36 stripePaymentIntentId: session.payment_intent,37 stripeSubscriptionId: session.subscription,38 message,39 status: 'completed'40 });4142 // Atomically increment campaign total43 await db.execute(44 require('drizzle-orm').sql`UPDATE campaigns SET current_amount = current_amount + ${amount} WHERE id = ${Number(campaignId)}`45 );4647 // Update donor total48 await db.execute(49 require('drizzle-orm').sql`UPDATE donors SET total_donated = total_donated + ${amount} WHERE id = ${Number(donorId)}`50 );51 }5253 if (event.type === 'invoice.paid') {54 const invoice = event.data.object;55 if (invoice.subscription) {56 const sub = await stripe.subscriptions.retrieve(invoice.subscription);57 const { campaignId, donorId } = sub.metadata;58 if (campaignId && donorId) {59 const amount = invoice.amount_paid;60 await db.insert(donations).values({61 campaignId: Number(campaignId), donorId: Number(donorId), amount,62 isRecurring: true, stripeSubscriptionId: invoice.subscription, status: 'completed'63 });64 await db.execute(65 require('drizzle-orm').sql`UPDATE campaigns SET current_amount = current_amount + ${amount} WHERE id = ${Number(campaignId)}`66 );67 }68 }69 }7071 res.json({ received: true });72});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.
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.
1// Prompt to type into Replit Agent:2// Build these React components:3//4// 1. CampaignPage at client/src/pages/CampaignPage.jsx:5// - Fetch GET /api/campaigns/:id on load6// - Show: title, description, image, creator name7// - Progress section:8// * Current amount in dollars (current_amount / 100)9// * Goal amount in dollars10// * Progress bar: width = (current_amount / goal_amount * 100)%11// * Donor count from GET /api/campaigns/:id/donors?count=true12// - Donate section:13// * Preset amount buttons: $10, $25, $50, $100, Other14// * Custom amount input (shown when 'Other' selected)15// * Monthly/One-time toggle16// * Name input, Email input17// * Anonymous checkbox18// * Message textarea (optional)19// * Donate button → POST /api/donate → redirect to session.url20//21// 2. DonorWall component:22// - Fetch GET /api/campaigns/:id/donors (shows approved completed donations)23// - Show last 20 donors in a scrollable list24// - Each donor: avatar initials circle, name (or 'Anonymous'), amount, message, time ago25// - 'Be the first to donate' empty state26//27// Add route: GET /api/campaigns/:id/donors28// Join donations + donors WHERE campaign_id=:id AND status='completed'29// Return is_anonymous ? {name: 'Anonymous', ...} : actual donor info30// Order by created_at DESC, limit 20Expected 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.
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.
1// Step-by-step deployment checklist:2//3// 1. Open Replit Secrets (lock icon in sidebar) and verify these exist:4// - STRIPE_SECRET_KEY (set by /stripe command)5// - STRIPE_PUBLISHABLE_KEY (set by /stripe command)6// - APP_URL — your Replit deployment URL (add this after first deploy)7// - SESSION_SECRET — any 32-character random string8//9// 2. Deploy to Autoscale: click Deploy → Autoscale in the top-right menu10// Your app URL will be: https://your-repl-name.your-username.repl.co11//12// 3. Go to Stripe Dashboard → Developers → Webhooks → Add endpoint13// Endpoint URL: https://your-deployment-url.repl.co/api/webhooks/stripe14// Events: checkout.session.completed, invoice.paid15// Click Add endpoint16//17// 4. Copy the Signing secret shown on the webhook details page18// Add it to Replit Secrets as: STRIPE_WEBHOOK_SECRET19//20// 5. Test: visit your deployment URL, go to a campaign, donate $1 with test card21// 4242 4242 4242 4242, any future expiry, any CVC22// Check Stripe Dashboard → Webhooks to see the event delivered successfullyPro 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
1const { Router } = require('express');2const Stripe = require('stripe');3const { db } = require('../db');4const { sql, eq } = require('drizzle-orm');5const { donations, campaigns, donors, webhookEvents } = require('../../shared/schema');67const router = Router();8const stripe = Stripe(process.env.STRIPE_SECRET_KEY);910// IMPORTANT: register with express.raw BEFORE express.json in server/index.js11router.post('/api/webhooks/stripe', async (req, res) => {12 const sig = req.headers['stripe-signature'];13 let event;1415 try {16 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);17 } catch (err) {18 return res.status(400).json({ error: 'Webhook signature failed: ' + err.message });19 }2021 const already = await db.select().from(webhookEvents)22 .where(eq(webhookEvents.stripeEventId, event.id));23 if (already.length > 0) return res.json({ received: true, status: 'duplicate' });2425 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });2627 try {28 if (event.type === 'checkout.session.completed') {29 const session = event.data.object;30 const { campaignId, donorId, message } = session.metadata;31 const amount = session.amount_total;3233 await db.insert(donations).values({34 campaignId: Number(campaignId), donorId: Number(donorId), amount,35 isRecurring: session.mode === 'subscription',36 stripePaymentIntentId: session.payment_intent || null,37 stripeSubscriptionId: session.subscription || null,38 message: message || null, status: 'completed'39 });4041 await db.execute(sql`UPDATE campaigns SET current_amount = current_amount + ${amount} WHERE id = ${Number(campaignId)}`);42 await db.execute(sql`UPDATE donors SET total_donated = total_donated + ${amount} WHERE id = ${Number(donorId)}`);43 }4445 if (event.type === 'invoice.paid' && event.data.object.subscription) {46 const invoice = event.data.object;47 const sub = await stripe.subscriptions.retrieve(invoice.subscription);48 const { campaignId, donorId } = sub.metadata || {};49 if (campaignId && donorId) {50 await db.insert(donations).values({51 campaignId: Number(campaignId), donorId: Number(donorId),52 amount: invoice.amount_paid, isRecurring: true,53 stripeSubscriptionId: invoice.subscription, status: 'completed'54 });55 await db.execute(sql`UPDATE campaigns SET current_amount = current_amount + ${invoice.amount_paid} WHERE id = ${Number(campaignId)}`);56 }57 }58 } catch (err) {59 console.error('Webhook processing error:', err);60 }6162 res.json({ received: true });63});6465module.exports = router;Customization ideas
Donation matching
Add a match_amount and matcher_name field to campaigns. When a new donation comes in via webhook, check if match_amount has been reached. If not, double the donation display in the donor wall and show a 'Your donation will be matched by [matcher]' message on the campaign page.
Donor recognition tiers
Add tier thresholds (Bronze $25, Silver $100, Gold $500) to the donor display. Compute each donor's cumulative total from the donations table and show a tier badge next to their name in the donor wall.
Campaign updates feed
Add a campaign_updates table (campaign_id, title, body, created_at). Campaign creators can post text updates visible below the donor wall. Email all past donors when a new update is posted, using SendGrid with the API key in Replit Secrets.
Common pitfalls
Pitfall: Using express.json() middleware before express.raw() on the webhook route
How to avoid: 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.
Pitfall: Testing webhooks before deploying
How to avoid: 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.
Pitfall: Not checking for duplicate webhook events before processing
How to avoid: 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.
Pitfall: Updating campaign totals by summing all donations on every webhook
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a donation system with Express, PostgreSQL, and Stripe Checkout. When the checkout.session.completed webhook fires, I need to atomically insert a donation record and increment the campaign's current_amount. Help me write the webhook handler that verifies the Stripe signature with constructEvent() (sync), checks for duplicate event IDs in a webhook_events table for idempotency, inserts the donation, and increments the campaign total using an UPDATE with addition — all in a way that prevents double-counting if the webhook is delivered twice.
Add a fundraiser leaderboard to the donation system. Create a fundraisers table (campaign_id, user_id, personal_goal, raised_amount, share_code). Fundraisers have their own share links (/fundraise/:shareCode) that track which donations came through their link (via metadata in the Stripe session). The leaderboard shows fundraisers ranked by raised_amount with progress toward their personal goal.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation