Bonaventure OgetoBy Bonaventure Ogeto|

Build a Crowdfunding Platform

Build a crowdfunding platform by letting creators set campaigns with targets and deadlines, collecting pledges through Paystack transactions, and either releasing funds to the creator when the target is met (all-or-nothing) or settling continuously (keep-it-all). Use Paystack metadata to track which campaign each pledge belongs to, and the Transfers API to pay creators after successful campaigns.

Architecture Overview

A crowdfunding platform connects creators who need funding with backers who want to support them. The key architectural difference from a donation platform is the concept of a campaign target and a deadline. In all-or-nothing crowdfunding (the Kickstarter model), if the target is not reached by the deadline, every backer gets refunded. In keep-it-all crowdfunding, the creator receives whatever was raised.

The all-or-nothing model is harder to build because you need to hold funds until the deadline, then either pay the creator or refund everyone. The keep-it-all model is simpler because you can settle funds to the creator as they come in.

The system flow for all-or-nothing:

  1. Creator launches a campaign with a title, description, target, deadline, and reward tiers
  2. Backers select a reward tier and pay through Paystack
  3. Funds are collected and held in your Paystack balance
  4. When the deadline arrives, your system checks if the target was met
  5. If yes: transfer funds to the creator minus your platform fee
  6. If no: refund all backers

The tech stack is Node.js with Express, PostgreSQL, and a cron job (or scheduled task) that runs at campaign deadlines to trigger payouts or refunds.

Data Model

You need five tables: creators, campaigns, reward_tiers, pledges, and payouts.

The campaigns table holds:

  • id: UUID
  • creator_id: Who created the campaign
  • title, description, cover_image_url: Campaign presentation
  • target_amount: Funding goal in smallest currency unit
  • raised_amount: Total raised so far (updated by webhooks)
  • currency: KES, NGN, GHS, ZAR
  • funding_model: all_or_nothing or keep_it_all
  • deadline: Timestamp when the campaign closes
  • status: draft, active, funded, failed, paid_out
  • backer_count: Number of confirmed backers

The reward_tiers table stores campaign rewards:

  • campaign_id: Parent campaign
  • title: Reward name (e.g., "Early Bird Special")
  • description: What the backer gets
  • amount: Minimum pledge for this tier
  • max_backers: Limit on how many can claim this (nullable for unlimited)
  • claimed_count: How many have pledged at this tier

The pledges table records each backer contribution:

  • backer_email: Backer email
  • campaign_id: Which campaign
  • reward_tier_id: Which tier they selected
  • amount: Pledge amount (may exceed the tier minimum)
  • paystack_reference: Transaction reference
  • paystack_transaction_id: For refund processing
  • status: pending, completed, refunded

Collecting Pledges

When a backer selects a reward tier and confirms their pledge:

var axios = require("axios");

async function createPledge(backerEmail, campaignId, rewardTierId, amount) {
  var campaign = await db.query("SELECT * FROM campaigns WHERE id = $1 AND status = $2", [campaignId, "active"]);
  if (!campaign) throw new Error("Campaign not active");

  if (new Date(campaign.deadline) < new Date()) throw new Error("Campaign has ended");

  var tier = await db.query("SELECT * FROM reward_tiers WHERE id = $1", [rewardTierId]);
  if (amount < tier.amount) throw new Error("Pledge must be at least " + tier.amount);

  if (tier.max_backers && tier.claimed_count >= tier.max_backers) {
    throw new Error("This reward tier is sold out");
  }

  var reference = "PLG-" + campaignId.slice(0, 8) + "-" + Date.now();

  var response = await axios.post(
    "https://api.paystack.co/transaction/initialize",
    {
      email: backerEmail,
      amount: amount,
      currency: campaign.currency,
      reference: reference,
      callback_url: "https://yourplatform.com/campaigns/" + campaignId + "/thank-you",
      metadata: {
        campaign_id: campaignId,
        reward_tier_id: rewardTierId,
        backer_email: backerEmail,
        campaign_title: campaign.title
      }
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  await db.query(
    "INSERT INTO pledges (backer_email, campaign_id, reward_tier_id, amount, paystack_reference, status) VALUES ($1, $2, $3, $4, $5, $6)",
    [backerEmail, campaignId, rewardTierId, amount, reference, "pending"]
  );

  return response.data.data.authorization_url;
}

For all-or-nothing campaigns, the money is charged immediately (not just authorized). It sits in your Paystack balance until you decide what to do with it at deadline time. This is simpler than using preauthorization, which has a limited hold period that might expire before the campaign deadline.

Webhook Handling

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 pledge = await db.query(
      "SELECT * FROM pledges WHERE paystack_reference = $1",
      [event.data.reference]
    );

    if (!pledge || pledge.status === "completed") {
      return res.status(200).send("OK");
    }

    await db.query(
      "UPDATE pledges SET status = $1, paystack_transaction_id = $2 WHERE id = $3",
      ["completed", event.data.id, pledge.id]
    );

    // Update campaign totals
    await db.query(
      "UPDATE campaigns SET raised_amount = raised_amount + $1, backer_count = backer_count + 1 WHERE id = $2",
      [pledge.amount, pledge.campaign_id]
    );

    // Update reward tier claimed count
    await db.query(
      "UPDATE reward_tiers SET claimed_count = claimed_count + 1 WHERE id = $1",
      [pledge.reward_tier_id]
    );

    await sendPledgeConfirmation(pledge.backer_email, meta.campaign_title, pledge.amount);
  }

  if (event.event === "refund.processed") {
    var reference = event.data.transaction.reference;
    await db.query(
      "UPDATE pledges SET status = $1 WHERE paystack_reference = $2",
      ["refunded", reference]
    );
  }

  res.status(200).send("OK");
});

Store the paystack_transaction_id (the numeric ID Paystack assigns to each transaction) in the pledge record. You need this for processing refunds later. The Refund API requires either the transaction ID or the reference.

Campaign Deadline Processing

At the campaign deadline, run a job that checks whether the target was met and either pays the creator or refunds backers:

async function processCampaignDeadline(campaignId) {
  var campaign = await db.query("SELECT * FROM campaigns WHERE id = $1", [campaignId]);

  if (campaign.status !== "active") return;

  if (campaign.funding_model === "all_or_nothing") {
    if (campaign.raised_amount >= campaign.target_amount) {
      // Campaign succeeded: pay the creator
      await db.query("UPDATE campaigns SET status = $1 WHERE id = $2", ["funded", campaignId]);
      await payCreator(campaign);
    } else {
      // Campaign failed: refund all backers
      await db.query("UPDATE campaigns SET status = $1 WHERE id = $2", ["failed", campaignId]);
      await refundAllBackers(campaignId);
    }
  } else {
    // keep_it_all: pay whatever was raised
    await db.query("UPDATE campaigns SET status = $1 WHERE id = $2", ["funded", campaignId]);
    if (campaign.raised_amount > 0) {
      await payCreator(campaign);
    }
  }
}

async function refundAllBackers(campaignId) {
  var pledges = await db.query(
    "SELECT * FROM pledges WHERE campaign_id = $1 AND status = $2",
    [campaignId, "completed"]
  );

  for (var i = 0; i < pledges.length; i++) {
    await axios.post(
      "https://api.paystack.co/refund",
      { transaction: pledges[i].paystack_transaction_id },
      {
        headers: {
          Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
        }
      }
    );
    // Small delay to avoid rate limiting
    await new Promise(function(resolve) { setTimeout(resolve, 500); });
  }
}

async function payCreator(campaign) {
  var creator = await db.query("SELECT * FROM creators WHERE id = $1", [campaign.creator_id]);
  var platformFee = Math.round(campaign.raised_amount * 0.05); // 5% platform fee
  var creatorPayout = campaign.raised_amount - platformFee;

  await axios.post(
    "https://api.paystack.co/transfer",
    {
      source: "balance",
      amount: creatorPayout,
      recipient: creator.paystack_recipient_code,
      reason: "Campaign payout: " + campaign.title,
      reference: "PAYOUT-" + campaign.id + "-" + Date.now()
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  await db.query("UPDATE campaigns SET status = $1 WHERE id = $2", ["paid_out", campaign.id]);
}

Run this function as a cron job that checks for campaigns past their deadline every hour. Alternatively, schedule a one-time job when each campaign is created, set to fire at the deadline timestamp.

Reward Tier Fulfillment

After a campaign is funded and the creator is paid, they need to fulfill reward promises. Build a fulfillment tracking system where the creator can mark each backer's reward as shipped, delivered, or fulfilled (for digital rewards).

Add a reward_fulfillments table with pledge_id, status (pending, shipped, delivered), tracking_number (for physical rewards), and timestamps. Give creators a dashboard where they can see all backers grouped by reward tier and update fulfillment status.

For digital rewards (early access to software, exclusive content), you can automate fulfillment. When the campaign is funded, generate access links or keys and send them to backers automatically.

Send backers an email when the campaign closes with their reward details: "The campaign reached its goal. Your [tier name] reward will be fulfilled by [estimated date]. Thank you for backing [campaign name]." Keep backers informed throughout the fulfillment process. The biggest complaint on crowdfunding platforms is silence after funding.

Platform Fees and Financial Reporting

Your platform revenue comes from the fee you deduct from successful campaign payouts. A standard model is 5% of the raised amount. Paystack also charges its own transaction fees on each pledge, which are separate from your platform fee.

Track three financial metrics per campaign: total pledged, total Paystack fees, and your platform fee. The creator payout is: total pledged minus Paystack fees (already deducted at transaction time) minus your platform fee.

Build an admin dashboard that shows all campaigns, their status, total platform revenue, and pending payouts. For accounting, export a monthly report with all completed campaigns, pledge amounts, refunds, platform fees earned, and creator payouts made.

For keep-it-all campaigns where you use subaccounts, the platform fee is handled automatically by the split. Your percentage_charge on the subaccount is set to (100 minus your fee percentage), and Paystack routes the money accordingly at settlement time.

Deployment and Testing

Test both funding models end to end in Paystack test mode:

  1. Create a campaign with a low target (e.g., 10,000 KES)
  2. Make enough test pledges to exceed the target
  3. Trigger the deadline processing and verify the creator payout
  4. Create another campaign and let it fail (do not reach the target)
  5. Trigger the deadline and verify all backers are refunded

Pay special attention to the refund flow for failed all-or-nothing campaigns. If you have 200 backers and need to refund them all, the process takes time and might encounter failures (expired cards, closed accounts). Build retry logic and flag any refunds that fail for manual review.

Deploy with a reliable cron scheduler. If the deadline processing job fails to run, campaigns will not close on time. Use a managed cron service or a process manager that restarts failed jobs. Log every deadline processing run with the campaign ID and outcome.

Key Takeaways

  • Decide between all-or-nothing (refund everyone if the target is not met) and keep-it-all (creator gets whatever is raised). This decision shapes your entire payment architecture.
  • For all-or-nothing campaigns, charge cards immediately but hold funds in your Paystack balance. Only transfer to the creator after the deadline if the target is met. Refund all backers if it is not.
  • Use Paystack subaccounts for keep-it-all campaigns. Funds settle directly to the creator with your platform fee deducted automatically.
  • Track pledges separately from completed payments. A pledge is an intent. A completed payment is confirmed money. Report campaign progress based on completed payments only.
  • Reward tiers add complexity but increase backer engagement. A campaign with 3-5 reward levels (basic supporter, early bird, VIP) typically raises more than one with a single "donate any amount" option.
  • Build a payout approval step. Before transferring funds to a creator, have an admin review the campaign to ensure it complies with your platform rules.

Frequently Asked Questions

Can I hold funds for all-or-nothing campaigns without a financial license?
This depends on your jurisdiction. In many African countries, holding customer funds for extended periods requires regulatory compliance. Consult a local fintech lawyer. One alternative is to use preauthorization (hold the charge on the card without capturing) but Paystack hold periods may be shorter than your campaign duration. Another option is to charge immediately and keep funds in your Paystack balance until the deadline.
How do I prevent fraudulent campaigns?
Implement a campaign review process. New campaigns go into "pending review" status. An admin reviews the campaign details, verifies the creator identity, and approves or rejects before it goes live. Also require identity verification for creators (KYC) before they can create their first campaign. This adds friction but protects your backers and your platform reputation.
Can backers increase their pledge after the initial payment?
Yes. Treat an increased pledge as a new transaction. The backer pays the difference through a new Paystack transaction. Create a new pledge record linked to the same campaign and reward tier. Their total contribution is the sum of all their pledges to that campaign.
What happens if a creator does not fulfill rewards?
This is the hardest problem in crowdfunding. Once funds are transferred to the creator, you have limited recourse. Mitigate this by holding a portion (say, 10%) of the payout until reward fulfillment is confirmed by backers, implementing a dispute resolution process, and banning creators who do not deliver. Be transparent with backers that pledge is not a purchase guarantee.

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