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.
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:
- Customer browses your product catalog and selects a product
- They click "Buy Now" and your backend initializes a Paystack transaction
- Customer pays through Paystack checkout
- Paystack sends a charge.success webhook to your backend
- Your backend creates a purchase record and generates a signed download URL
- The download link is sent to the customer via email and shown on the confirmation page
- 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: UUIDname: Product title (e.g., "Kenya Tax Guide for Freelancers")description: Full product descriptionprice: In smallest currency unitcurrency: KES, NGN, GHS, or ZARfile_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 purposesmax_downloads: How many times a buyer can download (default 5)status: active, inactive
The purchases table records each sale:
id: UUIDcustomer_email: Buyer emailproduct_id: What they boughtpaystack_reference: Transaction referencedownload_token: A unique token for generating download linksdownload_count: How many times they have downloadedstatus: completed, refundedpurchased_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).
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:
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:
- Create a test product with a small file
- Purchase it using a Paystack test card
- Verify the webhook creates the purchase record
- Verify the download email arrives with a working link
- Download the file and verify the download count increments
- Test the download limit by exceeding max_downloads
- 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.
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