Bonaventure OgetoBy Bonaventure Ogeto|

Build Paystack Subscriptions in Vue

To build Paystack subscriptions in Vue, create a plan on your backend using the Paystack Plans API, then initialize a transaction with the plan code. Paystack handles the recurring billing automatically. Your backend listens for subscription webhooks (subscription.create, invoice.payment_failed, subscription.disable) and updates your database. Your Vue frontend displays the subscription status by querying your backend API.

How Paystack Subscriptions Work

Paystack subscriptions follow a simple model. You create a plan that defines the amount, interval (daily, weekly, monthly, annually), and currency. When a customer pays for the first time using a transaction initialized with a plan code, Paystack creates a subscription and charges them automatically on the next billing date.

The flow for a Vue application:

  1. Your backend creates a plan on Paystack (one-time setup per pricing tier).
  2. Your Vue app shows the available plans by fetching them from your backend.
  3. When the customer clicks "Subscribe," Vue calls your backend to initialize a transaction with the plan code.
  4. Vue opens the Paystack popup. The customer pays.
  5. Paystack creates the subscription and starts recurring billing.
  6. Your backend receives webhooks for each billing event and updates the subscription status in your database.
  7. Your Vue app displays the current subscription status by querying your backend.

Vue handles steps 2, 3, 4, and 7. Everything else is backend work.

Create Plans on Your Backend

Create plans using the Paystack Plans API from your backend:

// server.js (Express)
var PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;

app.post('/api/plans', async function(req, res) {
  var response = await fetch('https://api.paystack.co/plan', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + PAYSTACK_SECRET,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: req.body.name,
      amount: req.body.amount, // in kobo
      interval: req.body.interval, // daily, weekly, monthly, quarterly, biannually, annually
      currency: 'NGN',
    }),
  });

  var data = await response.json();

  if (data.status) {
    // Save plan_code to your database
    res.json({ plan_code: data.data.plan_code, name: data.data.name });
  } else {
    res.status(400).json({ error: data.message });
  }
});

// Fetch available plans for Vue to display
app.get('/api/plans', async function(req, res) {
  // Return plans from your database
  var plans = [
    { name: 'Basic', plan_code: 'PLN_xxxbasic', amount: 500000, interval: 'monthly' },
    { name: 'Pro', plan_code: 'PLN_xxxpro', amount: 1500000, interval: 'monthly' },
  ];
  res.json({ plans: plans });
});

You typically create plans once (through the Paystack dashboard or a seed script) and store the plan codes in your database. Your Vue app fetches the list of plans from your backend, not from Paystack directly.

Subscription Checkout in Vue

Initialize a transaction with the plan code and open the Paystack popup:

<script setup>
import { ref, onMounted } from 'vue';

var API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
var plans = ref([]);
var loading = ref(false);
var selectedPlan = ref(null);
var email = ref('');

onMounted(async function() {
  var script = document.createElement('script');
  script.src = 'https://js.paystack.co/v2/inline.js';
  document.head.appendChild(script);

  var res = await fetch(API_URL + '/api/plans');
  var data = await res.json();
  plans.value = data.plans;
});

async function subscribe(plan) {
  if (!email.value) return;
  loading.value = true;
  selectedPlan.value = plan;

  var res = await fetch(API_URL + '/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: email.value,
      plan_code: plan.plan_code,
    }),
  });

  var data = await res.json();

  if (!data.access_code) {
    alert(data.error || 'Failed to initialize');
    loading.value = false;
    return;
  }

  var popup = new window.PaystackPop();
  popup.checkout({
    accessCode: data.access_code,
    onSuccess: async function(transaction) {
      var verifyRes = await fetch(
        API_URL + '/api/verify?reference=' + transaction.reference
      );
      var result = await verifyRes.json();

      if (result.verified) {
        alert('Subscription activated!');
      }
      loading.value = false;
    },
    onCancel: function() {
      loading.value = false;
    },
  });
}
</script>

<template>
  <div>
    <input v-model="email" type="email" placeholder="Your email" />
    <div v-for="plan in plans" :key="plan.plan_code">
      <h3>{{ plan.name }}</h3>
      <p>NGN {{ (plan.amount / 100).toLocaleString() }} / {{ plan.interval }}</p>
      <button @click="subscribe(plan)" :disabled="loading || !email">
        Subscribe
      </button>
    </div>
  </div>
</template>

Backend Subscribe Endpoint

The subscribe endpoint initializes a transaction with the plan code:

app.post('/api/subscribe', async function(req, res) {
  var email = req.body.email;
  var planCode = req.body.plan_code;

  var response = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + PAYSTACK_SECRET,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email: email,
      plan: planCode,
      // amount is not needed when a plan is provided
    }),
  });

  var data = await response.json();

  if (data.status) {
    res.json({
      access_code: data.data.access_code,
      reference: data.data.reference,
    });
  } else {
    res.status(400).json({ error: data.message });
  }
});

When you pass a plan to the Initialize Transaction endpoint, you do not need to pass an amount. Paystack pulls the amount from the plan. After the customer pays, Paystack automatically creates a subscription and schedules the next charge.

Handle Subscription Webhooks

Subscription lifecycle events arrive as webhooks. Your backend must handle these events to keep your database in sync:

var crypto = require('crypto');

app.post('/api/webhooks/paystack', express.raw({ type: 'application/json' }), function(req, res) {
  var hash = crypto
    .createHmac('sha512', PAYSTACK_SECRET)
    .update(req.body)
    .digest('hex');

  if (hash !== req.headers['x-paystack-signature']) {
    return res.status(400).send('Invalid signature');
  }

  var event = JSON.parse(req.body);

  if (event.event === 'subscription.create') {
    // New subscription created
    var sub = event.data;
    // Save: sub.subscription_code, sub.plan.plan_code, sub.customer.email, sub.next_payment_date
  }

  if (event.event === 'invoice.payment_failed') {
    // Recurring charge failed
    // Update subscription status to 'past_due'
    // Notify the customer via email
  }

  if (event.event === 'subscription.not_renew') {
    // Customer cancelled, subscription will not renew
    // Keep access until current period ends
  }

  if (event.event === 'subscription.disable') {
    // Subscription fully disabled
    // Revoke access
  }

  if (event.event === 'charge.success') {
    // Could be initial or recurring charge
    // Update payment history
  }

  res.sendStatus(200);
});

Vue cannot receive webhooks. This endpoint runs on your backend. See Handle Paystack Webhooks in Vue for the architectural explanation.

Display Subscription Status in Vue

Create a composable that fetches the subscription status from your backend:

// src/composables/useSubscription.js
import { ref, onMounted } from 'vue';

var API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';

export function useSubscription() {
  var subscription = ref(null);
  var loading = ref(true);

  onMounted(async function() {
    try {
      var res = await fetch(API_URL + '/api/subscription', {
        headers: { Authorization: 'Bearer ' + localStorage.getItem('token') },
      });
      var data = await res.json();
      subscription.value = data.subscription;
    } catch (err) {
      subscription.value = null;
    } finally {
      loading.value = false;
    }
  });

  return { subscription, loading };
}

Use it in a subscription management page:

<script setup>
import { useSubscription } from '../composables/useSubscription';

var { subscription, loading } = useSubscription();
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="subscription">
    <p>Plan: {{ subscription.planName }}</p>
    <p>Status: {{ subscription.status }}</p>
    <p>Next billing: {{ subscription.nextPaymentDate }}</p>
    <a :href="subscription.manageUrl" target="_blank">Manage Subscription</a>
  </div>
  <div v-else>
    <p>No active subscription.</p>
  </div>
</template>

Let Customers Cancel

Paystack provides two ways to let customers manage subscriptions:

  1. Paystack-hosted management link: When you create a subscription, Paystack returns a manage_url that the customer can visit to cancel or update their card. Store this link and show it in your Vue UI.
  2. API cancellation: Your backend calls the Disable Subscription endpoint:
app.post('/api/subscription/cancel', async function(req, res) {
  var response = await fetch('https://api.paystack.co/subscription/disable', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + PAYSTACK_SECRET,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      code: req.body.subscription_code,
      token: req.body.email_token,
    }),
  });

  var data = await response.json();
  res.json(data);
});

The email_token is sent in the subscription creation webhook. Store it alongside the subscription code. After cancellation, the customer keeps access until the current billing period ends.

Ship Payments Faster

Key Takeaways

  • Subscription logic lives on the backend. Vue displays the subscription state and collects the initial payment.
  • Create plans on your backend using the Paystack Plans API. Do not hardcode plan codes in frontend code.
  • Initialize the subscription checkout by passing the plan code when initializing a transaction. Paystack handles recurring billing after the first charge.
  • Listen for subscription webhooks on your backend: subscription.create, invoice.payment_failed, invoice.update, and subscription.not_renew.
  • Show subscription status in Vue by querying your backend API. Use a computed property or composable for clean state management.
  • Customers can manage their subscription (cancel, update card) through a Paystack-hosted link or through your own UI backed by the Subscriptions API.

Frequently Asked Questions

Can I create Paystack plans from the Vue frontend?
No. The Plans API requires your secret key. Create plans from your backend or from the Paystack dashboard. Your Vue app fetches the available plans from your backend API.
Does Paystack charge the customer automatically after the first payment?
Yes. When you initialize a transaction with a plan code, Paystack creates a subscription after the first successful charge and handles all future billing automatically based on the plan interval.
How do I know if a recurring charge failed?
Paystack sends an invoice.payment_failed webhook to your backend. Listen for this event and update the subscription status in your database. You should also notify the customer so they can update their card.
Can the customer update their payment card?
Yes. Paystack provides a manage_url with every subscription that customers can visit to update their card details. You can also use the Update Subscription endpoint on your backend to change the card programmatically.
What happens when a subscription is cancelled?
The customer keeps access until the end of the current billing period. Paystack will not charge them again. You receive a subscription.not_renew webhook when the customer cancels and a subscription.disable webhook when the subscription is fully deactivated.

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