Build a paywalled newsletter by creating a Paystack Plan for your subscription tier, subscribing readers when they pay, and using webhook events to manage access. Deliver premium content only to subscribers with an active status. Listen for invoice.payment_failed and subscription.not_renew events to handle churn gracefully, sending reminders before revoking access.
A paywalled newsletter is one of the simplest subscription products you can build. Readers subscribe, pay a recurring fee through Paystack, and receive premium content. The architecture has three components: a subscriber management system, a content delivery layer, and Paystack for billing.
The flow works like this:
Content delivery can work in two ways: you can send premium posts via email to active subscribers, or you can publish them on a website and gate access behind a login. Most newsletters do both. The email is the primary delivery channel, and the website serves as an archive for subscribers.
The tech stack is Node.js with Express, PostgreSQL for subscriber and content storage, and a transactional email service (SendGrid, Resend, or Mailgun) for delivery.
You need three tables: subscribers, posts, and delivery_logs.
The subscribers table tracks each reader:
id: UUIDemail: The subscriber email (also used for Paystack)name: Display nametier: free or premiumstatus: active, past_due, cancelledpaystack_customer_code: Paystack customer identifierpaystack_subscription_code: Active subscription referencepaystack_email_token: Token for subscription management linksubscribed_at: When they first subscribedexpires_at: When the current billing period endsThe posts table stores your newsletter content:
id: UUIDtitle: Post titlecontent: Full HTML contentexcerpt: A preview paragraph shown to free readersis_premium: Whether this post is behind the paywallpublished_at: When the post goes livestatus: draft, publishedThe delivery_logs table records each email sent, tracking which subscriber received which post, whether the email was opened, and whether any links were clicked. This data is essential for understanding engagement and reducing churn.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Create your subscription plan on Paystack. Most newsletters offer a monthly and an annual option:
var axios = require("axios");
async function createPlans() {
var monthlyPlan = await axios.post(
"https://api.paystack.co/plan",
{
name: "Premium Newsletter - Monthly",
amount: 100000, // KES 1,000 in cents
interval: "monthly",
currency: "KES"
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
var annualPlan = await axios.post(
"https://api.paystack.co/plan",
{
name: "Premium Newsletter - Annual",
amount: 1000000, // KES 10,000 in cents (saving 2 months)
interval: "annually",
currency: "KES"
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
// Store plan codes in your config or database
console.log("Monthly plan: " + monthlyPlan.data.data.plan_code);
console.log("Annual plan: " + annualPlan.data.data.plan_code);
}
You only create plans once. After that, every new subscriber gets linked to the same plan. If you want to change the price for new subscribers without affecting existing ones, create a new plan with the new price and update your subscription page to use the new plan code. Existing subscribers continue on their original plan until they cancel.
When a reader clicks "Subscribe," initialize a transaction with the plan attached:
async function subscribe(email, name, planCode) {
// Create or find subscriber
var subscriber = await db.query(
"INSERT INTO subscribers (email, name, tier, status) VALUES ($1, $2, $3, $4) ON CONFLICT (email) DO UPDATE SET name = $2 RETURNING *",
[email, name, "free", "active"]
);
var response = await axios.post(
"https://api.paystack.co/transaction/initialize",
{
email: email,
amount: 100000, // Must match plan amount
plan: planCode,
currency: "KES",
callback_url: "https://yournewsletter.com/welcome",
metadata: {
subscriber_id: subscriber.id,
plan_type: planCode.indexOf("monthly") > -1 ? "monthly" : "annual"
}
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
return response.data.data.authorization_url;
}
After payment, Paystack creates the subscription and starts the billing cycle. The subscriber gets charged automatically each month (or year) without any action from your side.
For the welcome page, show a confirmation message with what to expect: "You are now a premium subscriber. Expect your first premium post in your inbox this Friday. Check your spam folder and add our email to your contacts."
Your webhook handler needs to manage the full subscription lifecycle:
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 === "subscription.create") {
var data = event.data;
await db.query(
"UPDATE subscribers SET tier = $1, status = $2, paystack_subscription_code = $3, paystack_customer_code = $4, paystack_email_token = $5, subscribed_at = NOW(), expires_at = $6 WHERE email = $7",
["premium", "active", data.subscription_code, data.customer.customer_code, data.email_token, data.next_payment_date, data.customer.email]
);
}
if (event.event === "invoice.payment_failed") {
var email = event.data.customer.email;
await db.query(
"UPDATE subscribers SET status = $1 WHERE email = $2",
["past_due", email]
);
await sendPaymentFailedReminder(email);
}
if (event.event === "subscription.not_renew" || event.event === "subscription.disable") {
var subscriptionCode = event.data.subscription_code;
await db.query(
"UPDATE subscribers SET tier = $1, status = $2 WHERE paystack_subscription_code = $3",
["free", "cancelled", subscriptionCode]
);
await sendCancellationEmail(subscriptionCode);
}
if (event.event === "charge.success" && event.data.plan) {
// Successful renewal
var email = event.data.customer.email;
await db.query(
"UPDATE subscribers SET status = $1, expires_at = $2 WHERE email = $3",
["active", event.data.plan_object.next_payment_date, email]
);
}
res.status(200).send("OK");
});
The past_due status is important. It means the payment failed but Paystack is still retrying. Do not immediately downgrade the subscriber to free. Give Paystack a chance to collect the payment. Only downgrade when you receive the subscription.not_renew or subscription.disable event.
When you publish a new post, send it to all relevant subscribers via email. Premium posts go to premium subscribers. Free posts go to everyone:
async function publishPost(postId) {
var post = await db.query("SELECT * FROM posts WHERE id = $1", [postId]);
var subscribers;
if (post.is_premium) {
subscribers = await db.query(
"SELECT * FROM subscribers WHERE tier = $1 AND status IN ($2, $3)",
["premium", "active", "past_due"]
);
} else {
subscribers = await db.query(
"SELECT * FROM subscribers WHERE status != $1",
["cancelled"]
);
}
for (var i = 0; i < subscribers.length; i++) {
await sendEmail({
to: subscribers[i].email,
subject: post.title,
html: post.content
});
await db.query(
"INSERT INTO delivery_logs (subscriber_id, post_id, sent_at) VALUES ($1, $2, NOW())",
[subscribers[i].id, post.id]
);
}
await db.query("UPDATE posts SET status = $1, published_at = NOW() WHERE id = $2", ["published", postId]);
}
Notice that past_due subscribers still receive premium content. This is a business decision. Being generous during payment issues reduces churn because the subscriber still sees the value they are paying for. If you cut them off immediately, they have no motivation to fix their payment method.
For the web archive, gate premium posts behind authentication and subscription status:
router.get("/api/posts/:postId", async function(req, res) {
var post = await db.query("SELECT * FROM posts WHERE id = $1", [req.params.postId]);
if (!post.is_premium) {
return res.json({ post: post });
}
if (!req.user) {
return res.json({ post: { title: post.title, excerpt: post.excerpt, is_premium: true } });
}
var subscriber = await db.query(
"SELECT * FROM subscribers WHERE email = $1 AND tier = $2",
[req.user.email, "premium"]
);
if (!subscriber) {
return res.json({ post: { title: post.title, excerpt: post.excerpt, is_premium: true } });
}
res.json({ post: post });
});
Free readers see the title and excerpt. Premium subscribers see the full content. The excerpt should be compelling enough to make free readers want to upgrade.
Paystack provides a hosted subscription management page where subscribers can update their payment method or cancel. You generate the link using the email_token you stored during subscription creation:
function getManagementLink(emailToken) {
return "https://paystack.com/manage/subscriptions/" + emailToken;
}
Include this link in every newsletter email footer: "Manage your subscription" linking to this URL. This lets subscribers update their card details without you having to build a payment management UI.
For cancellations, you can also cancel a subscription programmatically from your admin dashboard:
async function cancelSubscription(subscriberId) {
var subscriber = await db.query("SELECT * FROM subscribers WHERE id = $1", [subscriberId]);
await axios.post(
"https://api.paystack.co/subscription/disable",
{
code: subscriber.paystack_subscription_code,
token: subscriber.paystack_email_token
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
// The webhook will update the subscriber status
}
When a subscriber cancels, they should retain access until the end of their current billing period. Track this using the expires_at field. Your content gating logic should check: "Is this subscriber premium AND is their expiry date in the future?" This way, a subscriber who cancels on day 5 of a monthly cycle still gets 25 more days of content.
Deploy with the webhook endpoint accessible over HTTPS. Test the complete lifecycle in Paystack test mode: subscribe, receive a post, simulate a failed payment, verify the past_due flow, cancel, and verify the downgrade.
For growth, focus on two levers: converting free readers to premium, and reducing churn among existing premium subscribers.
To convert free readers, occasionally publish a premium post that starts with a compelling first paragraph (the excerpt) and then cuts off with a "Subscribe to read the rest" message. Give free readers enough value that they trust the quality, but not so much that they never need to upgrade.
To reduce churn, send a "Your payment failed" email immediately when you receive the invoice.payment_failed webhook. Make it helpful, not threatening: "Your card might have expired. Update your payment details here: [management link]. Your premium access continues while we retry." Then send a follow-up 3 days later if the payment has not been resolved.
Track your key metrics: total subscribers, premium conversion rate, monthly churn rate, and average revenue per subscriber. A good newsletter has a churn rate below 5% per month and a premium conversion rate above 5% of free readers.
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