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.
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:
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.
You need five tables: creators, campaigns, reward_tiers, pledges, and payouts.
The campaigns table holds:
id: UUIDcreator_id: Who created the campaigntitle, description, cover_image_url: Campaign presentationtarget_amount: Funding goal in smallest currency unitraised_amount: Total raised so far (updated by webhooks)currency: KES, NGN, GHS, ZARfunding_model: all_or_nothing or keep_it_alldeadline: Timestamp when the campaign closesstatus: draft, active, funded, failed, paid_outbacker_count: Number of confirmed backersThe reward_tiers table stores campaign rewards:
campaign_id: Parent campaigntitle: Reward name (e.g., "Early Bird Special")description: What the backer getsamount: Minimum pledge for this tiermax_backers: Limit on how many can claim this (nullable for unlimited)claimed_count: How many have pledged at this tierThe pledges table records each backer contribution:
backer_email: Backer emailcampaign_id: Which campaignreward_tier_id: Which tier they selectedamount: Pledge amount (may exceed the tier minimum)paystack_reference: Transaction referencepaystack_transaction_id: For refund processingstatus: pending, completed, refundedGet weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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.
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.
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.
Test both funding models end to end in Paystack test mode:
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.
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