Bonaventure OgetoBy Bonaventure Ogeto|

Building a Membership Site on Paystack

Create a Paystack plan (monthly or annually) via the dashboard or API. Initialize a transaction with the plan code to subscribe a member. After subscription.create webhook, set the user as active in your database. Gate content with a middleware that checks membership status. Handle invoice.payment_failed to restrict access after failed renewal and subscription.disable to remove access on cancellation.

Plans and Subscription Flow

// Create a plan via API (or do this once in the dashboard)
app.post('/api/admin/plans', async (req, res) => {
  var { name, amount, interval } = req.body;
  var response = await fetch('https://api.paystack.co/plan', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ name, amount: amount * 100, interval }),
    // interval: 'monthly' | 'quarterly' | 'annually'
  });
  var data = await response.json();
  res.json({ plan_code: data.data.plan_code });
});

// Subscribe a member
app.post('/api/subscribe', async (req, res) => {
  var { email, planCode } = req.body;
  var reference = 'mem_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);

  var response = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email,
      plan: planCode,
      reference,
      metadata: { action: 'subscribe', plan_code: planCode },
    }),
  });

  var data = await response.json();
  res.json({ authorization_url: data.data.authorization_url, reference });
});

Webhook Handler and Content Gating

// Webhook handler
if (event.event === 'subscription.create') {
  var sub = event.data;
  await db.users.update({
    membership_status: 'active',
    subscription_code: sub.subscription_code,
    email_token: sub.email_token,
    plan_code: sub.plan.plan_code,
    plan_name: sub.plan.name,
    membership_expires_at: sub.next_payment_date,
  }, { where: { email: sub.customer.email } });
}

if (event.event === 'charge.success' && event.data.plan) {
  await db.users.update({
    membership_status: 'active',
    membership_expires_at: event.data.plan.next_payment_date,
  }, { where: { email: event.data.customer.email } });
}

if (event.event === 'invoice.payment_failed') {
  await db.users.update(
    { membership_status: 'past_due' },
    { where: { email: event.data.customer.email } }
  );
  await sendCardUpdateEmail(event.data.customer.email);
}

if (event.event === 'subscription.disable') {
  await db.users.update(
    { membership_status: 'cancelled' },
    { where: { email: event.data.customer.email } }
  );
}

// Content gating middleware
function requireMembership(req, res, next) {
  var user = req.user;
  var accessAllowed = user.membership_status === 'active' ||
    (user.membership_status === 'past_due' && isWithinGracePeriod(user));

  if (!accessAllowed) {
    return res.status(403).json({ error: 'Membership required', upgrade_url: '/pricing' });
  }
  next();
}

function isWithinGracePeriod(user) {
  if (user.membership_status !== 'past_due') return false;
  var graceDays = 3;
  var graceEnd = new Date(user.membership_expires_at);
  graceEnd.setDate(graceEnd.getDate() + graceDays);
  return new Date() < graceEnd;
}

Learn More

Key Takeaways

  • Create plans in the Paystack dashboard or via POST /plan. Tiers (basic, pro, enterprise) are separate plans with different amounts.
  • Subscribe a user by initializing a transaction with their plan code. Paystack handles billing date calculation.
  • On subscription.create webhook, set user.membership_status = "active" and store the subscription_code and email_token.
  • Gate routes with a middleware that checks membership_status and membership_expires_at from your database.
  • On invoice.payment_failed, show a card update prompt but do not immediately revoke access — give a 3-day grace period.
  • On subscription.disable, set membership_status = "cancelled" and restrict access at the period end date.

Frequently Asked Questions

How do I offer annual billing at a discount compared to monthly?
Create two separate Paystack plans: one monthly and one annual. Set the annual plan amount to 10x the monthly (giving two months free, for example). Display both options on your pricing page. When the user selects annual, initialize the transaction with the annual plan code.
Can members upgrade or downgrade their plan?
Paystack does not have a native upgrade/downgrade endpoint. The standard approach: cancel the current subscription (POST /subscription/disable) and immediately start a new subscription with the new plan code. Handle proration manually if needed — credit unused days or charge a prorated top-up.
How do I handle a member who cancels mid-billing-period?
Paystack does not automatically prorate or refund the unused portion. Your product decision: either grant access until the paid period ends (recommended — simpler), or refund the unused portion manually via the Paystack refund API. Clearly state your cancellation policy in your terms of service.
What is the best way to let members manage their own subscription?
Build a member portal with three actions: (1) Update card — link to re-subscribe flow for new authorization, (2) Cancel — call your cancel endpoint which calls /subscription/disable, (3) Billing history — query your payments table. The Paystack customer portal (paystack.co/manage) is also an option for basic self-service.

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