McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Build a Donation Platform with Paystack
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 22, 2026

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.

In this article

  1. 1. Architecture Overview
  2. 2. Data Model
  3. 3. Accepting Donations
  4. 4. Webhook Handling and Campaign Updates
  5. 5. Recurring Donations
  6. 6. Campaign Page and Progress Display
  7. 7. Donation Receipts and Reporting
  8. 8. Deployment and Testing

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:

  1. An organization creates a campaign with a title, description, target amount, and deadline
  2. A donor visits the campaign page and enters the amount they want to give
  3. Your backend validates the amount, creates a donation record, and initializes a Paystack transaction
  4. The donor pays through Paystack checkout
  5. Paystack sends a charge.success webhook
  6. Your backend updates the donation status to "completed" and adds the amount to the campaign total
  7. 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: UUID
  • organization_id: Who created the campaign
  • title: Campaign name
  • description: Full description with the story behind the cause
  • target_amount: Goal in smallest currency unit
  • raised_amount: Total raised so far (updated by webhooks)
  • currency: KES, NGN, GHS, or ZAR
  • donation_count: Number of completed donations
  • deadline: When the campaign closes (nullable for open-ended campaigns)
  • status: active, completed, closed

The donations table records each contribution:

  • id: UUID
  • campaign_id: Which campaign this supports
  • donor_email: Required by Paystack
  • donor_name: Optional (null for anonymous)
  • amount: In smallest currency unit
  • is_anonymous: Boolean
  • is_recurring: Boolean
  • paystack_reference: Transaction reference
  • status: pending, completed, failed
  • message: Optional message from the donor
  • receipt_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.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

KES 2,999

The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.

Start Paystack Course

Not ready? Create Free Account

Keep reading

Things You Can Build with Paystack: Project Guides

A complete map of real-world projects you can build using the Paystack API. From online store checkouts and subscription platforms to savings apps and solar pay-as-you-go systems, each category links to step-by-step build guides with working code.

Read more

Build a Laundry Pickup and Payment App

A build guide for a laundry pickup and delivery app that uses Paystack to collect payments. Covers order pricing by weight or item count, pickup scheduling, status tracking through the wash cycle, payment collection at order creation or delivery, and customer notifications.

Read more

Build an Affiliate Commission Engine

A build guide for an affiliate commission engine where affiliates share tracking links, earn commissions when their referrals convert (pay via Paystack), and withdraw their balance via bank transfer. Covers click tracking, last-click attribution, commission ledger, and Paystack Transfers payout.

Read more

Build a Farmers Market Ordering Platform

A build guide for a farmers market ordering platform where customers order fresh produce from multiple farmers in one cart, the platform collects the combined payment via Paystack, and each farmer receives their share via Paystack Split Payments or Transfers. Covers inventory management, order routing to individual farmers, and delivery coordination.

Read more

Migrating from Direct Daraja to Paystack

A practical migration guide for teams moving from direct Safaricom Daraja to Paystack. Why you might migrate, what changes in your checkout and webhook flows, how to run both providers in parallel during the transition, and how to roll back if something goes wrong.

Read more

Paystack Split Payments and Marketplaces: Complete Guide

Everything you need to split payments on Paystack: creating subaccounts, choosing between percentage and flat splits, multi-split transactions, fee configuration, delayed settlement, escrow-style patterns, and building real marketplace products with working Node.js code.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.