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.
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:
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.
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, inactiveThe 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: TimestampThe 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.
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.
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.
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.
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.
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.
Test the full flow in Paystack test mode:
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.
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