Build Paystack Subscriptions in Nuxt
To build Paystack subscriptions in Nuxt, create plans via a server route using the Plans API. Initialize transactions with the plan code so Paystack handles recurring billing. Listen for subscription webhooks (subscription.create, invoice.payment_failed, subscription.disable) in a server route. Display subscription status in your Vue pages by fetching from your own API.
How Paystack Subscriptions Work with Nuxt
Paystack subscriptions are built on plans. A plan defines the amount, billing interval, and currency. When a customer completes a transaction that includes a plan code, Paystack creates a subscription and charges the customer automatically on the next billing date.
In Nuxt, the full flow runs in a single project:
- Server route: Creates plans (one-time setup) and stores plan codes in your database.
- Server route: Returns available plans to the frontend.
- Page component: Displays plans and opens the Paystack checkout popup with the selected plan.
- Server route: Initializes the transaction with the plan code.
- Paystack: Processes the first charge and creates the subscription.
- Server route: Receives subscription webhooks and updates the database.
- Page component: Displays the subscription status from your database.
Create Plans via Server Route
// server/api/plans/create.post.ts
export default defineEventHandler(async (event) => {
var config = useRuntimeConfig();
var body = await readBody(event);
var response = await fetch('https://api.paystack.co/plan', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + config.paystackSecretKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: body.name,
amount: body.amount,
interval: body.interval,
currency: body.currency || 'NGN',
}),
});
var data = await response.json();
if (!data.status) {
throw createError({ statusCode: 400, message: data.message });
}
// Save to database
// await db.plans.insertOne({
// plan_code: data.data.plan_code,
// name: data.data.name,
// amount: data.data.amount,
// interval: data.data.interval,
// });
return {
plan_code: data.data.plan_code,
name: data.data.name,
amount: data.data.amount,
interval: data.data.interval,
};
});
You typically create plans once during setup. After that, your pages fetch the list of available plans from your database through a GET server route.
// server/api/plans/index.get.ts
export default defineEventHandler(async (event) => {
// Fetch plans from your database
// var plans = await db.plans.find({ active: true }).toArray();
var plans = [
{ plan_code: 'PLN_basic123', name: 'Basic', amount: 500000, interval: 'monthly' },
{ plan_code: 'PLN_pro456', name: 'Pro', amount: 1500000, interval: 'monthly' },
];
return { plans: plans };
});
Subscription Checkout Page
<!-- pages/pricing.vue -->
<script setup>
useHead({
script: [{ src: 'https://js.paystack.co/v2/inline.js', defer: true }],
});
var { data: plansData } = await useFetch('/api/plans');
var email = ref('');
var loading = ref(false);
async function subscribe(planCode) {
if (!email.value) return;
loading.value = true;
try {
var data = await $fetch('/api/subscribe', {
method: 'POST',
body: { email: email.value, plan_code: planCode },
});
var popup = new window.PaystackPop();
popup.checkout({
accessCode: data.access_code,
onSuccess: async function(transaction) {
await $fetch('/api/verify', {
params: { reference: transaction.reference },
});
navigateTo('/dashboard');
loading.value = false;
},
onCancel: function() {
loading.value = false;
},
});
} catch (err) {
alert('Failed to start checkout');
loading.value = false;
}
}
</script>
<template>
<div>
<h1>Choose a Plan</h1>
<input v-model="email" type="email" placeholder="Email" />
<div v-for="plan in plansData?.plans" :key="plan.plan_code">
<h3>{{ plan.name }}</h3>
<p>NGN {{ (plan.amount / 100).toLocaleString() }} / {{ plan.interval }}</p>
<button @click="subscribe(plan.plan_code)" :disabled="loading || !email">
Subscribe
</button>
</div>
</div>
</template>
Subscribe Server Route
// server/api/subscribe.post.ts
export default defineEventHandler(async (event) => {
var config = useRuntimeConfig();
var body = await readBody(event);
var response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + config.paystackSecretKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: body.email,
plan: body.plan_code,
// amount is pulled from the plan automatically
}),
});
var data = await response.json();
if (!data.status) {
throw createError({ statusCode: 400, message: data.message });
}
return {
access_code: data.data.access_code,
reference: data.data.reference,
};
});
When you pass a plan parameter, you omit the amount. Paystack pulls the amount from the plan definition. After the first successful charge, Paystack creates the subscription automatically.
Handle Subscription Webhooks
// server/api/webhooks/paystack.post.ts
import crypto from 'crypto';
export default defineEventHandler(async (event) => {
var config = useRuntimeConfig();
var rawBody = await readRawBody(event);
if (!rawBody) {
throw createError({ statusCode: 400, message: 'Empty body' });
}
var signature = getHeader(event, 'x-paystack-signature');
var hash = crypto
.createHmac('sha512', config.paystackSecretKey)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
throw createError({ statusCode: 400, message: 'Invalid signature' });
}
var payload = JSON.parse(rawBody);
if (payload.event === 'subscription.create') {
var sub = payload.data;
// Save subscription: sub.subscription_code, sub.customer.email,
// sub.plan.plan_code, sub.next_payment_date, sub.email_token
console.log('Subscription created:', sub.subscription_code);
}
if (payload.event === 'charge.success') {
// Could be initial or recurring charge
console.log('Charge successful:', payload.data.reference);
}
if (payload.event === 'invoice.payment_failed') {
// Recurring charge failed
// Update subscription status to past_due, notify customer
console.log('Invoice payment failed:', payload.data.subscription.subscription_code);
}
if (payload.event === 'subscription.not_renew') {
// Customer cancelled, will not renew at end of period
console.log('Subscription will not renew:', payload.data.subscription_code);
}
if (payload.event === 'subscription.disable') {
// Subscription fully deactivated
// Revoke access
console.log('Subscription disabled:', payload.data.subscription_code);
}
return { received: true };
});
Store the email_token from the subscription.create event. You need it later to cancel the subscription programmatically.
Display and Manage Subscriptions
<!-- pages/dashboard.vue -->
<script setup>
var { data: subData } = await useFetch('/api/subscription');
var cancelling = ref(false);
async function cancelSubscription() {
if (!confirm('Cancel your subscription?')) return;
cancelling.value = true;
try {
await $fetch('/api/subscription/cancel', { method: 'POST' });
subData.value.subscription.status = 'cancelled';
} catch (err) {
alert('Failed to cancel. Please try again.');
} finally {
cancelling.value = false;
}
}
</script>
<template>
<div v-if="subData?.subscription">
<h2>Your Subscription</h2>
<p>Plan: {{ subData.subscription.planName }}</p>
<p>Status: {{ subData.subscription.status }}</p>
<p>Next billing: {{ subData.subscription.nextPaymentDate }}</p>
<button v-if="subData.subscription.status === 'active'" @click="cancelSubscription" :disabled="cancelling">
Cancel Subscription
</button>
</div>
<div v-else>
<p>No active subscription.</p>
<NuxtLink to="/pricing">View Plans</NuxtLink>
</div>
</template>
The cancel endpoint calls Paystack's Disable Subscription API:
// server/api/subscription/cancel.post.ts
export default defineEventHandler(async (event) => {
var config = useRuntimeConfig();
// Get the user's subscription from your database
// var sub = await db.subscriptions.findOne({ userId: event.context.auth.userId });
var response = await fetch('https://api.paystack.co/subscription/disable', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + config.paystackSecretKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: 'SUB_xxxx', // sub.subscription_code
token: 'xxxx', // sub.email_token
}),
});
var data = await response.json();
if (!data.status) {
throw createError({ statusCode: 400, message: data.message });
}
// Update database
// await db.subscriptions.updateOne({ userId: event.context.auth.userId }, { status: 'cancelled' });
return { cancelled: true };
});
Key Takeaways
- ✓Nuxt handles everything in one project: plan management in server routes, checkout in pages, and webhook handling in server routes.
- ✓Create plans using the Paystack Plans API from a server route. Store plan codes in your database.
- ✓Initialize a subscription by passing the plan code to the transaction initialization endpoint. Paystack handles recurring billing after the first charge.
- ✓Handle subscription webhooks in a dedicated server route: subscription.create, invoice.payment_failed, subscription.not_renew, and subscription.disable.
- ✓Show subscription status by fetching from your own server route. Do not call the Paystack API from the client.
- ✓Let customers cancel via the Paystack manage link or through your own UI backed by the Disable Subscription API.
Frequently Asked Questions
- Can I manage Paystack subscriptions entirely in Nuxt?
- Yes. Nuxt server routes handle plan creation, subscription initialization, webhook processing, and cancellation. You do not need a separate backend.
- What is the email_token and why do I need it?
- The email_token is returned in the subscription.create webhook event. You need it along with the subscription_code to cancel a subscription programmatically via the Disable Subscription API. Store it when you receive the webhook.
- How does Paystack handle failed recurring charges?
- Paystack retries failed charges over several days. It sends invoice.payment_failed and invoice.update webhook events. If all retries fail, it disables the subscription and sends a subscription.disable event.
- Can I change a subscription plan in Paystack?
- Paystack does not support direct plan changes on an existing subscription. To switch plans, cancel the current subscription and create a new one on the new plan. The customer will need to go through checkout again.
- What happens to access when a customer cancels?
- The customer keeps access until the end of the current billing period. Paystack sends subscription.not_renew immediately when cancelled and subscription.disable when the period ends. Use these events to manage access in your database.
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