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 Digital Product Download Store
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 22, 2026

Build a Digital Product Download Store

Build a digital product download store by hosting files in cloud storage (S3 or Supabase Storage), initializing Paystack transactions when customers buy, and generating time-limited signed download URLs only after webhook confirmation. The webhook handler creates a purchase record, generates the signed URL, and sends it to the customer via email. Never expose raw file URLs to the public.

In this article

  1. 1. Architecture Overview
  2. 2. Data Model
  3. 3. Secure File Storage
  4. 4. Payment and Automatic Delivery
  5. 5. Webhook Handling and Download Link Generation
  6. 6. Download Endpoint with Rate Limiting
  7. 7. Product Bundles and Multiple Files
  8. 8. Deployment and Testing

Architecture Overview

A digital product store has a simpler fulfillment flow than a physical goods store. There is no shipping, no inventory depletion, and no delivery logistics. Instead, you have a different challenge: controlling access to files that can be infinitely copied once downloaded.

The flow works like this:

  1. Customer browses your product catalog and selects a product
  2. They click "Buy Now" and your backend initializes a Paystack transaction
  3. Customer pays through Paystack checkout
  4. Paystack sends a charge.success webhook to your backend
  5. Your backend creates a purchase record and generates a signed download URL
  6. The download link is sent to the customer via email and shown on the confirmation page
  7. Customer downloads the file using the signed URL

The critical security layer is the signed URL. Your files sit in private cloud storage where nobody can access them directly. When a purchase is verified, you generate a temporary URL that grants access for a limited time (say, 4 hours). After the URL expires, the customer can request a new one from their purchase history page, as long as their download count has not exceeded the limit.

The tech stack is Node.js with Express, PostgreSQL for product and purchase data, and cloud storage (AWS S3, Supabase Storage, or any S3-compatible service) for files.

Data Model

You need three tables: products, purchases, and downloads.

The products table stores your catalog:

  • id: UUID
  • name: Product title (e.g., "Kenya Tax Guide for Freelancers")
  • description: Full product description
  • price: In smallest currency unit
  • currency: KES, NGN, GHS, or ZAR
  • file_key: The storage path in your cloud bucket (e.g., "products/tax-guide-2026.pdf")
  • file_type: pdf, zip, mp3, etc.
  • file_size_bytes: For display purposes
  • max_downloads: How many times a buyer can download (default 5)
  • status: active, inactive

The purchases table records each sale:

  • id: UUID
  • customer_email: Buyer email
  • product_id: What they bought
  • paystack_reference: Transaction reference
  • download_token: A unique token for generating download links
  • download_count: How many times they have downloaded
  • status: completed, refunded
  • purchased_at: Timestamp

The downloads table logs every download attempt for auditing: purchase_id, IP address, user agent, and timestamp. This helps you detect abuse (same file downloaded 50 times from different IPs suggests link sharing).

Get weekly developer tips

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

No spam. Unsubscribe anytime.

Secure File Storage

Store your digital products in a private cloud storage bucket. If you are using Supabase Storage, create a private bucket. If using S3, set the bucket policy to block all public access.

Upload products through an admin interface or a script:

var AWS = require("aws-sdk");

var s3 = new AWS.S3({
  accessKeyId: process.env.AWS_ACCESS_KEY,
  secretAccessKey: process.env.AWS_SECRET_KEY,
  region: process.env.AWS_REGION
});

async function uploadProduct(filePath, fileKey) {
  var fs = require("fs");
  var fileBuffer = fs.readFileSync(filePath);

  await s3.putObject({
    Bucket: process.env.S3_BUCKET,
    Key: fileKey,
    Body: fileBuffer,
    ContentType: "application/pdf"
  }).promise();

  return fileKey;
}

The file_key (e.g., "products/tax-guide-2026.pdf") is what you store in your products table. This key is never exposed to customers. They only see signed URLs that point to this key with an expiration timestamp and a cryptographic signature.

For Supabase Storage, the approach is similar. Create a private bucket, upload files through the Supabase dashboard or API, and generate signed URLs using the Supabase client library.

Payment and Automatic Delivery

When a customer clicks "Buy Now," initialize a Paystack transaction:

var axios = require("axios");
var crypto = require("crypto");

async function purchaseProduct(customerEmail, productId) {
  var product = await db.query("SELECT * FROM products WHERE id = $1 AND status = $2", [productId, "active"]);
  if (!product) throw new Error("Product not found");

  var downloadToken = crypto.randomBytes(32).toString("hex");
  var reference = "DL-" + productId.slice(0, 8) + "-" + Date.now();

  var response = await axios.post(
    "https://api.paystack.co/transaction/initialize",
    {
      email: customerEmail,
      amount: product.price,
      currency: product.currency,
      reference: reference,
      callback_url: "https://yourstore.com/download/" + downloadToken,
      metadata: {
        product_id: productId,
        product_name: product.name,
        download_token: downloadToken,
        customer_email: customerEmail
      }
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  // Pre-create the purchase record with pending status
  await db.query(
    "INSERT INTO purchases (customer_email, product_id, paystack_reference, download_token, download_count, status) VALUES ($1, $2, $3, $4, $5, $6)",
    [customerEmail, productId, reference, downloadToken, 0, "pending"]
  );

  return response.data.data.authorization_url;
}

The download_token is generated before payment and stored in metadata. After payment, the customer is redirected to a URL containing this token. But the actual file delivery only happens after the webhook confirms payment.

Webhook Handling and Download Link Generation

When the webhook confirms payment, generate the signed download URL and email it to the customer:

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 purchase = await db.query(
      "SELECT * FROM purchases WHERE download_token = $1",
      [meta.download_token]
    );

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

    // Verify amount
    var product = await db.query("SELECT * FROM products WHERE id = $1", [meta.product_id]);
    if (event.data.amount !== product.price) {
      return res.status(200).send("OK");
    }

    // Update purchase to completed
    await db.query(
      "UPDATE purchases SET status = $1, purchased_at = NOW() WHERE download_token = $2",
      ["completed", meta.download_token]
    );

    // Generate signed download URL
    var downloadUrl = generateSignedUrl(product.file_key, 14400); // 4 hours

    // Email the download link
    await sendEmail({
      to: meta.customer_email,
      subject: "Your download is ready: " + product.name,
      html: "

Thank you for your purchase. Download your file here:

Download " + product.name + "

This link expires in 4 hours. You can generate a new link from your purchase history.

" }); } res.status(200).send("OK"); }); function generateSignedUrl(fileKey, expiresInSeconds) { var params = { Bucket: process.env.S3_BUCKET, Key: fileKey, Expires: expiresInSeconds }; return s3.getSignedUrl("getObject", params); }

The signed URL is the key security mechanism. It contains a cryptographic signature that S3 verifies. After the expiration time, S3 rejects the URL. Even if someone shares the link, it stops working after 4 hours.

Download Endpoint with Rate Limiting

Build a download endpoint that verifies the purchase, checks the download count, generates a fresh signed URL, and redirects the customer:

router.get("/download/:token", async function(req, res) {
  var purchase = await db.query(
    "SELECT p.*, pr.file_key, pr.max_downloads, pr.name as product_name FROM purchases p JOIN products pr ON p.product_id = pr.id WHERE p.download_token = $1",
    [req.params.token]
  );

  if (!purchase || purchase.status !== "completed") {
    return res.status(404).json({ error: "Purchase not found or not completed" });
  }

  if (purchase.download_count >= purchase.max_downloads) {
    return res.status(403).json({
      error: "Download limit reached. Contact support for assistance.",
      downloads_used: purchase.download_count,
      max_downloads: purchase.max_downloads
    });
  }

  // Increment download count
  await db.query(
    "UPDATE purchases SET download_count = download_count + 1 WHERE download_token = $1",
    [req.params.token]
  );

  // Log the download
  await db.query(
    "INSERT INTO downloads (purchase_id, ip_address, user_agent, downloaded_at) VALUES ($1, $2, $3, NOW())",
    [purchase.id, req.ip, req.headers["user-agent"]]
  );

  // Generate fresh signed URL and redirect
  var downloadUrl = generateSignedUrl(purchase.file_key, 3600); // 1 hour
  res.redirect(downloadUrl);
});

This endpoint serves double duty. It is both the page the customer lands on after payment (via the callback_url) and the link they use from the email. The token-based approach means you do not need authentication for downloads, which simplifies the customer experience. They click the link in the email and get the file.

Set max_downloads to something generous like 5. Honest customers rarely download more than twice (once on their laptop, once on their phone). But hardware failures happen, and you do not want to frustrate a paying customer who just got a new device.

Product Bundles and Multiple Files

Some digital products are bundles: a course with 10 PDF chapters, a design kit with 50 templates, or a music album with 12 tracks. Handle this by adding a product_files table that links multiple files to a single product:

// Schema:
// product_files: id, product_id, file_key, file_name, file_type, file_size_bytes, order_index

router.get("/download/:token/files", async function(req, res) {
  var purchase = await db.query(
    "SELECT * FROM purchases WHERE download_token = $1 AND status = $2",
    [req.params.token, "completed"]
  );

  if (!purchase) {
    return res.status(404).json({ error: "Purchase not found" });
  }

  var files = await db.query(
    "SELECT id, file_name, file_type, file_size_bytes FROM product_files WHERE product_id = $1 ORDER BY order_index",
    [purchase.product_id]
  );

  // Generate signed URLs for each file
  var filesWithUrls = files.map(function(file) {
    return {
      name: file.file_name,
      type: file.file_type,
      size: file.file_size_bytes,
      url: generateSignedUrl(file.file_key, 3600)
    };
  });

  res.json({ files: filesWithUrls });
});

For bundles, show a download page that lists all files with individual download links rather than trying to zip everything into one file on the fly. Zipping large files on request is slow and resource-intensive. Let customers download what they need individually.

Deployment and Testing

Test the full flow in Paystack test mode:

  1. Create a test product with a small file
  2. Purchase it using a Paystack test card
  3. Verify the webhook creates the purchase record
  4. Verify the download email arrives with a working link
  5. Download the file and verify the download count increments
  6. Test the download limit by exceeding max_downloads
  7. Test a refund and verify the purchase status updates

Deploy the backend on Railway or Render with your S3 bucket configured. Make sure the webhook URL is publicly accessible. For file storage, use a bucket in a region close to your primary customer base. If most customers are in East Africa, an S3 bucket in eu-west-1 or af-south-1 gives reasonable download speeds.

Monitor two things in production: webhook processing failures (which mean customers paid but did not receive their download) and download errors (which mean the signed URL generation or S3 access is broken). Set up alerts on both. A customer who pays and does not receive their product will dispute the charge.

Key Takeaways

  • ✓Never expose raw file URLs. Use signed URLs with short expiration times (2-4 hours) so that shared links stop working quickly.
  • ✓Generate the download link only after receiving the charge.success webhook. The redirect callback is not proof of payment.
  • ✓Store files in private cloud storage (S3, Supabase Storage, or Cloudinary). Set bucket permissions to deny all public access.
  • ✓Use Paystack metadata to link transactions to specific products. Include the product_id in the metadata so your webhook handler knows which file to deliver.
  • ✓Send the download link via email in addition to showing it on the redirect page. Customers close browser tabs, lose the page, or get interrupted. Email is the reliable delivery channel.
  • ✓Track download counts per purchase. Limit downloads to a reasonable number (say, 5 per purchase) to discourage link sharing while being generous enough that honest customers never hit the limit.

Frequently Asked Questions

How do I prevent customers from sharing download links?
Signed URLs with short expiration times (2-4 hours) make casual sharing impractical. Download count limits add another layer. Watermarking PDFs with the buyer email is a stronger measure for high-value products. But accept that some sharing is inevitable with digital products. Focus on making the purchase experience so smooth that people prefer buying to hunting for shared links.
Can I sell software licenses or API keys instead of files?
Yes. Replace the file download logic with license key generation. When the webhook confirms payment, generate a unique license key, store it in the purchase record, and email it to the customer. The same payment flow works; only the delivery mechanism changes.
How do I handle refunds for digital products?
Use the Paystack Refund API. When you process a refund, update the purchase status to "refunded." On the next download attempt, check the status and deny access. You cannot un-download a file, but revoking future access is the standard approach. Have a clear refund policy visible on your product pages.
Can I offer free products alongside paid ones?
Yes. For free products, skip the Paystack transaction. When a customer requests a free product, create the purchase record directly with status "completed" and send the download link. You still get the customer email, which is valuable for marketing future paid products.
What file size limits should I consider?
S3 handles files up to 5 TB. The practical limit is download time on African networks. A 500 MB file takes over 10 minutes on a 5 Mbps connection. For large files, consider splitting them into smaller chunks or offering a torrent option. For most digital products (ebooks, templates, music), files under 100 MB work well.

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

Apple Pay for Kenyan Merchants on Paystack

How to set up and accept Apple Pay through Paystack as a Kenyan merchant. Covers domain verification, which devices and browsers support Apple Pay, the customer experience, how tokenized card payments work under the hood, when Apple Pay matters in the Kenyan market, and the integration code.

Read more

Split Payment Reconciliation and Subaccount Reporting

Split payment reconciliation means verifying that every vendor received the right amount from every transaction. This guide covers querying Paystack transaction and settlement data, building per-vendor earnings reports from your database, and finding discrepancies between expected and actual settlement.

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.