Bonaventure OgetoBy Bonaventure Ogeto|

Build a Streaming Service Billing Backend

Build a streaming billing backend using Paystack Plans (one per tier), subscribing users via the transaction initialize endpoint, gating content access on active subscription status in your database, and listening to subscription webhooks (subscription.create, invoice.payment_failed, subscription.disable) to grant or revoke access automatically.

Subscription Plans and Content Access

Tables: subscription_plans (name, price, quality, concurrent_streams, paystack_plan_code), user_subscriptions (user_id, plan_id, status, paystack_subscription_code, current_period_start, current_period_end), content (title, min_plan_tier, video_url_sd, video_url_hd, video_url_4k).

var PLANS = {
  basic:    { tier: 1, quality: 'SD',  streams: 1, price: 120000 }, // NGN 1,200/month
  standard: { tier: 2, quality: 'HD',  streams: 2, price: 250000 }, // NGN 2,500/month
  premium:  { tier: 3, quality: '4K',  streams: 4, price: 450000 }, // NGN 4,500/month
};

async function subscribeUser(userId, userEmail, planName) {
  var plan = await db.subscriptionPlans.findByName(planName);

  var res = 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: userEmail, amount: plan.price, plan: plan.paystack_plan_code,
      metadata: { user_id: userId, plan_id: plan.id },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Content access gate
async function getStreamUrl(userId, contentId) {
  var sub = await db.userSubscriptions.findActiveByUser(userId);
  if (!sub) throw { status: 403, message: 'Subscribe to watch this content.' };

  var content = await db.content.findById(contentId);
  var userPlan = PLANS[sub.plan_name];

  if (userPlan.tier < content.min_plan_tier) {
    throw { status: 403, message: 'Upgrade to ' + content.required_plan + ' to watch this.' };
  }

  // Return appropriate quality URL based on plan
  if (userPlan.quality === '4K') return content.video_url_4k;
  if (userPlan.quality === 'HD') return content.video_url_hd;
  return content.video_url_sd;
}

Subscription Lifecycle via Webhooks

if (event.event === 'subscription.create') {
  var meta = event.data.metadata;
  await db.userSubscriptions.upsert({
    user_id: meta.user_id,
    plan_id: meta.plan_id,
    paystack_subscription_code: event.data.subscription_code,
    status: 'active',
    current_period_end: new Date(event.data.next_payment_date),
  });
}

if (event.event === 'invoice.create' && event.data.status === 'success') {
  // Subscription renewed — extend period
  var code = event.data.subscription.subscription_code;
  await db.userSubscriptions.update({ code }, {
    status: 'active',
    current_period_end: new Date(event.data.next_payment_date),
  });
}

if (event.event === 'invoice.payment_failed') {
  // Grace period — do not cut access immediately
  var code = event.data.subscription.subscription_code;
  await db.userSubscriptions.update({ code }, { status: 'grace_period' });
  await sendPaymentFailedEmail(code);
}

if (event.event === 'subscription.not_renew' || event.event === 'subscription.disable') {
  var code = event.data.subscription_code;
  await db.userSubscriptions.update({ code }, { status: 'cancelled' });
  await sendCancelledEmail(code);
}

// Plan upgrade: cancel old, start new
async function upgradePlan(userId, userEmail, newPlanName) {
  var currentSub = await db.userSubscriptions.findActiveByUser(userId);
  if (currentSub) {
    // Disable old subscription
    await fetch('https://api.paystack.co/subscription/disable', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({ code: currentSub.paystack_subscription_code, token: userEmail }),
    });
  }
  return await subscribeUser(userId, userEmail, newPlanName);
}

Learn More

See build a podcast membership platform for a simpler single-tier subscription access model.

Sign up for the McTaba newsletter

Key Takeaways

  • Create one Paystack Plan per subscription tier (Basic, Standard, Premium) — do not create plans per user.
  • Gate content quality (SD vs HD vs 4K) and concurrent streams based on the user's active plan tier.
  • Handle invoice.payment_failed with a 3-day grace period before downgrading access.
  • Support plan upgrades: cancel the old subscription and start a new one at the higher tier.
  • Use Paystack customer objects to associate all subscriptions with one customer profile.

Frequently Asked Questions

How do I handle concurrent stream limits?
Track active streams per user in a Redis cache with session tokens. When a user starts streaming, create a session with a 30-second heartbeat. If the user's active session count exceeds their plan limit, block new stream starts with a "Too many devices" message. Reset session count when all streams expire or the user logs out. Heartbeats prevent stale sessions from blocking legitimate streams.
How do I let users cancel and still keep access until the end of the billing period?
Call Paystack's disable subscription endpoint (cancels future renewals but does not immediately terminate). On your end, keep the subscription status as "active" until current_period_end. When that date passes without a renewal, set status to "cancelled". The user keeps access for the rest of the period they paid for.
Should I offer a free trial?
Yes if conversion is your primary metric. Paystack Plans support free trial periods via the invoice_limit parameter. Alternatively, create a 7-day free plan that auto-converts to a paid plan. For the free trial to work with card-on-file, the user must enter their card upfront — this reduces trial sign-up volume but increases conversion rate to paid.

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