Build a Paywalled Newsletter
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.
Architecture Overview
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:
- A reader visits your newsletter landing page and clicks "Subscribe"
- Your backend initializes a Paystack transaction with a Plan attached
- The reader pays through Paystack checkout
- Paystack creates a subscription and sends a subscription.create webhook
- Your backend creates a subscriber record with status "active"
- Each month, Paystack automatically charges the subscriber and sends an invoice.create event
- If payment fails, Paystack sends invoice.payment_failed and retries
- If the subscription is cancelled, Paystack sends subscription.not_renew
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.
Data Model
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 ends
The 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, published
The 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.
Creating Paystack Plans
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.
Subscriber Signup and Payment
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."
Webhook Handling
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.
Content Delivery and Gating
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.
Subscription Management
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.
Deployment and Growth
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.
Key Takeaways
- ✓Use Paystack Plans for recurring billing. Create one plan per tier (monthly, annual) and let Paystack handle the charge cycle automatically.
- ✓Gate premium content on the server side. Your API should check subscriber status before returning any premium newsletter content.
- ✓Do not revoke access immediately on a failed payment. Send a reminder, wait for Paystack retries, and only disable access when the subscription is actually cancelled.
- ✓Offer both free and premium tiers. Free subscribers see excerpts and occasional full posts. Premium subscribers see everything. This gives free readers a taste of what they are missing.
- ✓Store the Paystack subscription_code and email_token for each subscriber. You need the email_token to let subscribers manage their subscription (update card, cancel) through the Paystack-hosted management page.
- ✓Track open rates and click rates for premium content. This helps you understand what your paying subscribers value and guides your editorial decisions.
Frequently Asked Questions
- Can I offer a free trial before charging?
- Paystack does not support native free trials on plans, but you can implement one yourself. When a reader signs up for a trial, create the subscriber record with a trial_ends_at date. After the trial expires, prompt them to subscribe through the normal Paystack flow. Do not collect card details during the trial unless you plan to charge automatically at trial end.
- How do I handle subscribers who share the premium content with non-subscribers?
- You cannot fully prevent email forwarding, and trying too hard to prevent it can annoy paying subscribers. Focus on making the subscription valuable enough that people want their own access. For the web archive, require authentication. For emails, include personalized elements (like the subscriber name) that make sharing slightly less convenient.
- Can I have multiple pricing tiers beyond free and premium?
- Yes. Create a separate Paystack Plan for each tier with different amounts. Store the tier name in your subscribers table. Gate content based on the tier: basic subscribers see some premium posts, VIP subscribers see all premium posts plus bonus content. Each tier maps to a different plan_code.
- How do I migrate existing email subscribers to the paid platform?
- Import your existing subscribers into the subscribers table with tier set to "free" and status set to "active." They continue receiving free content. Then create a campaign promoting the premium tier. Do not auto-subscribe anyone to a paid plan without their explicit consent and payment.
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