Build a Donation Platform with Paystack
Build a donation platform by creating campaigns with fundraising targets, accepting variable-amount payments through Paystack, tracking progress toward each target, and optionally supporting recurring donations via Paystack Plans. Use webhook events to update campaign totals in real time and generate donation receipts for tax purposes. Store each donation with the campaign ID and donor details in metadata.
Architecture Overview
A donation platform connects organizations that need funds with people who want to give. Unlike an e-commerce store where the price is fixed, donations let the payer choose how much to give. This means the amount is dynamic and comes from user input, which requires careful validation.
The flow works like this:
- An organization creates a campaign with a title, description, target amount, and deadline
- A donor visits the campaign page and enters the amount they want to give
- Your backend validates the amount, creates a donation record, and initializes a Paystack transaction
- The donor pays through Paystack checkout
- Paystack sends a charge.success webhook
- Your backend updates the donation status to "completed" and adds the amount to the campaign total
- The campaign page updates to show the new progress
The tech stack is Node.js with Express, PostgreSQL for campaigns, donations, and donor records, and any frontend framework for the campaign pages. Optionally integrate an email service for donation receipts.
An important design consideration: campaign totals must be calculated from verified webhook data, not from initialized transactions. If a donor starts a payment but abandons it, the campaign total should not change. Only count money that actually arrived.
Data Model
You need three tables: campaigns, donations, and donors.
The campaigns table stores each fundraising effort:
id: UUIDorganization_id: Who created the campaigntitle: Campaign namedescription: Full description with the story behind the causetarget_amount: Goal in smallest currency unitraised_amount: Total raised so far (updated by webhooks)currency: KES, NGN, GHS, or ZARdonation_count: Number of completed donationsdeadline: When the campaign closes (nullable for open-ended campaigns)status: active, completed, closed
The donations table records each contribution:
id: UUIDcampaign_id: Which campaign this supportsdonor_email: Required by Paystackdonor_name: Optional (null for anonymous)amount: In smallest currency unitis_anonymous: Booleanis_recurring: Booleanpaystack_reference: Transaction referencestatus: pending, completed, failedmessage: Optional message from the donorreceipt_number: Generated after payment confirmation
The donors table tracks unique donors across campaigns for relationship management. Store their email, name, total lifetime giving, and number of campaigns supported. This helps organizations identify their most loyal supporters.
Accepting Donations
When a donor clicks "Donate" and enters an amount, your backend validates and initializes the transaction:
var axios = require("axios");
async function createDonation(campaignId, donorEmail, donorName, amount, isAnonymous, message) {
var campaign = await db.query("SELECT * FROM campaigns WHERE id = $1 AND status = $2", [campaignId, "active"]);
if (!campaign) throw new Error("Campaign not found or closed");
// Validate amount
if (amount < 10000) throw new Error("Minimum donation is 100 " + campaign.currency);
if (amount > 100000000) throw new Error("Maximum donation is 1,000,000 " + campaign.currency);
var reference = "DON-" + campaignId.slice(0, 8) + "-" + Date.now();
// Create donation record
var donation = await db.query(
"INSERT INTO donations (campaign_id, donor_email, donor_name, amount, is_anonymous, message, paystack_reference, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
[campaignId, donorEmail, isAnonymous ? null : donorName, amount, isAnonymous, message, reference, "pending"]
);
var response = await axios.post(
"https://api.paystack.co/transaction/initialize",
{
email: donorEmail,
amount: amount,
currency: campaign.currency,
reference: reference,
callback_url: "https://yourplatform.com/campaigns/" + campaignId + "/thank-you",
metadata: {
campaign_id: campaignId,
donation_id: donation.id,
donor_name: isAnonymous ? "Anonymous" : donorName,
campaign_title: campaign.title,
custom_fields: [
{ display_name: "Campaign", variable_name: "campaign", value: campaign.title },
{ display_name: "Donor", variable_name: "donor", value: isAnonymous ? "Anonymous" : donorName }
]
}
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
return response.data.data.authorization_url;
}
The amount validation is important. Without a minimum, someone could donate 1 kobo and create a real transaction that costs you in processing overhead. Without a maximum, a typo could result in a massive accidental charge and a guaranteed chargeback.
Webhook Handling and Campaign Updates
When the webhook confirms a donation, update the donation status, increment the campaign total, and generate a receipt:
var crypto = require("crypto");
router.post("/webhooks/paystack", express.raw({ type: "application/json" }), async function(req, res) {
var hash = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
.update(req.body)
.digest("hex");
if (hash !== req.headers["x-paystack-signature"]) {
return res.status(401).send("Invalid signature");
}
var event = JSON.parse(req.body);
if (event.event === "charge.success") {
var meta = event.data.metadata;
var donation = await db.query(
"SELECT * FROM donations WHERE id = $1",
[meta.donation_id]
);
if (!donation || donation.status === "completed") {
return res.status(200).send("OK");
}
// Verify amount matches
if (event.data.amount !== donation.amount) {
return res.status(200).send("OK");
}
var receiptNumber = "RCP-" + Date.now() + "-" + Math.floor(Math.random() * 1000);
// Update donation
await db.query(
"UPDATE donations SET status = $1, receipt_number = $2 WHERE id = $3",
["completed", receiptNumber, donation.id]
);
// Update campaign totals atomically
await db.query(
"UPDATE campaigns SET raised_amount = raised_amount + $1, donation_count = donation_count + 1 WHERE id = $2",
[donation.amount, donation.campaign_id]
);
// Check if campaign reached target
var campaign = await db.query("SELECT * FROM campaigns WHERE id = $1", [donation.campaign_id]);
if (campaign.raised_amount >= campaign.target_amount) {
await db.query("UPDATE campaigns SET status = $1 WHERE id = $2", ["completed", campaign.id]);
}
// Send receipt email
await sendDonationReceipt(donation, receiptNumber, campaign);
// Update or create donor record
await db.query(
"INSERT INTO donors (email, name, total_donated, donation_count) VALUES ($1, $2, $3, 1) ON CONFLICT (email) DO UPDATE SET total_donated = donors.total_donated + $3, donation_count = donors.donation_count + 1",
[donation.donor_email, donation.donor_name, donation.amount]
);
}
res.status(200).send("OK");
});
The atomic update on raised_amount (raised_amount = raised_amount + $1) is critical. If two donations complete simultaneously, this SQL guarantees both amounts are added correctly. Do not read the current total, add in JavaScript, and write back. That creates a race condition where one donation overwrites the other.
Recurring Donations
Many donors prefer to give monthly. Support this by creating a Paystack Plan for recurring donations to a campaign:
async function createRecurringDonation(campaignId, donorEmail, donorName, monthlyAmount) {
var campaign = await db.query("SELECT * FROM campaigns WHERE id = $1", [campaignId]);
// Create a plan for this specific amount
var planResponse = await axios.post(
"https://api.paystack.co/plan",
{
name: campaign.title + " Monthly - " + monthlyAmount,
amount: monthlyAmount,
interval: "monthly",
currency: campaign.currency
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
var response = await axios.post(
"https://api.paystack.co/transaction/initialize",
{
email: donorEmail,
amount: monthlyAmount,
plan: planResponse.data.data.plan_code,
currency: campaign.currency,
metadata: {
campaign_id: campaignId,
donor_name: donorName,
is_recurring: true
}
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
return response.data.data.authorization_url;
}
Since donation amounts are variable, you may end up creating many plans (one per unique monthly amount). This is acceptable because Paystack does not limit the number of plans you can create. Alternatively, you can use Paystack Charge Authorization to charge the saved card on a schedule you control, which gives you more flexibility.
For each recurring charge that succeeds, your webhook handler processes it the same way as a one-time donation: update the campaign total, generate a receipt, and log the donation.
Campaign Page and Progress Display
The public campaign page should show the fundraising progress, recent donations, and a donation form. Build an API endpoint that returns all the data needed:
router.get("/api/campaigns/:id", async function(req, res) {
var campaign = await db.query("SELECT * FROM campaigns WHERE id = $1", [req.params.id]);
if (!campaign) return res.status(404).json({ error: "Campaign not found" });
var recentDonations = await db.query(
"SELECT donor_name, amount, is_anonymous, message, created_at FROM donations WHERE campaign_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT 20",
[campaign.id, "completed"]
);
// Format for display
var displayDonations = recentDonations.map(function(d) {
return {
name: d.is_anonymous ? "Anonymous" : d.donor_name,
amount: d.amount,
message: d.message,
date: d.created_at
};
});
var progressPercent = Math.min(
Math.round((campaign.raised_amount / campaign.target_amount) * 100),
100
);
res.json({
campaign: {
title: campaign.title,
description: campaign.description,
target: campaign.target_amount,
raised: campaign.raised_amount,
progress: progressPercent,
donors: campaign.donation_count,
currency: campaign.currency,
status: campaign.status,
deadline: campaign.deadline
},
recent_donations: displayDonations
});
});
Display the progress as both a percentage bar and absolute numbers: "KES 450,000 raised of KES 1,000,000 goal (45%) from 127 donors." Showing the donor count creates social proof. People are more likely to donate when they see others have given too.
If the campaign has a deadline, show a countdown. Urgency drives donations. "3 days left" gets more clicks than an open-ended appeal.
Donation Receipts and Reporting
Generate a donation receipt after each confirmed payment. The receipt should include:
- Your organization name and registration number
- Receipt number (unique per donation)
- Donor name (or "Anonymous")
- Amount donated with currency
- Date of donation
- Campaign name
- A statement that the donation is tax-deductible (if applicable)
For organizations in Kenya, donors may need receipts for KRA tax deduction claims if the organization is a registered charity. In Nigeria, similar tax benefits exist for donations to approved organizations. Include the relevant registration number on the receipt.
Build an admin dashboard for organizations to see their campaign performance: total raised, number of donors, average donation size, recurring vs one-time split, and top donors. Export this data as CSV for accounting purposes.
router.get("/api/campaigns/:id/report", async function(req, res) {
var stats = await db.query(
"SELECT COUNT(*) as total_donations, SUM(amount) as total_raised, AVG(amount) as avg_donation, COUNT(CASE WHEN is_recurring THEN 1 END) as recurring_count FROM donations WHERE campaign_id = $1 AND status = $2",
[req.params.id, "completed"]
);
res.json({ report: stats });
});
Deployment and Testing
Test the full donation flow in Paystack test mode: create a campaign, make a donation with a test card, verify the webhook updates the campaign total, check the receipt email, and test a recurring donation setup.
Test edge cases: a donation to a campaign that has already reached its target, a donation to a closed campaign (should be rejected), and two simultaneous donations (the campaign total should be correct).
Deploy on Railway, Render, or Vercel with a PostgreSQL database. Set up the webhook URL in your Paystack dashboard and verify it works with a test event.
For production, consider adding a share feature that generates social media links with campaign details. Donations increase significantly when donors can share the campaign with their networks. "I just donated KES 1,000 to [campaign]. Join me: [link]" is powerful marketing that costs you nothing.
Key Takeaways
- ✓Let donors choose their own amount. Pass the donor-specified amount to the Paystack Initialize Transaction endpoint. Validate minimums and maximums on your backend.
- ✓Track campaign progress by summing verified donations (from webhooks), not from transaction initializations. A started payment is not a completed payment.
- ✓Support anonymous donations by making the donor name optional. Store the email (required by Paystack) but display "Anonymous" on the public campaign page if the donor chooses.
- ✓For recurring donations, create a Paystack Plan and subscribe the donor. They get charged automatically each month without any action from your side.
- ✓Generate donation receipts with a unique receipt number, donor name, amount, date, and your organization registration number. Many donors need receipts for tax deductions.
- ✓Use Paystack metadata to attach the campaign_id to every transaction. This is how your webhook handler knows which campaign total to update.
Frequently Asked Questions
- Can donors give without creating an account?
- Yes. The only required field from the donor is their email address (Paystack requires it). You can make the name, message, and account creation optional. Many donation platforms find that reducing friction increases conversion. Let people give first and optionally create an account later.
- How do I handle donations in multiple currencies?
- Each campaign should have a fixed currency. If you want to accept donations in both KES and NGN, create separate campaigns or separate Paystack transactions per currency. Mixing currencies in a single campaign total makes reporting confusing. Display the amount in the campaign currency.
- Can I allow organizations to withdraw funds before the campaign ends?
- Paystack settles funds to your bank account on a regular schedule regardless of campaign status. The settlement goes to the main account tied to your Paystack integration. If you are acting as a platform for multiple organizations, use subaccounts so each organization receives their funds directly. Otherwise, you hold the funds and disburse manually.
- How do I handle a donor who wants to cancel a recurring donation?
- Use the Paystack subscription management link (generated from the email_token) to let donors manage their own subscription. Include this link in every recurring donation receipt email. You can also cancel their subscription programmatically from your admin dashboard using the Paystack Disable Subscription endpoint.
- Should I allow donations after a campaign reaches its target?
- This is a business decision. Some platforms close the campaign automatically when the target is reached. Others allow overfunding with a message like "Goal reached! Extra funds will support [purpose]." If you allow overfunding, be transparent about where the extra money goes. If you close at target, clearly communicate this on the campaign page.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon