Coupons and Discounts on Paystack Subscriptions
Paystack has no built-in coupon or discount feature. You have two options: create separate Paystack plans at discounted prices and subscribe customers to those plans, or manage coupons in your application and use charge_authorization to charge discounted amounts. The second approach is more flexible but requires building your own billing engine. For simple "first month free" or "20% off first 3 months" promotions, the discounted plan approach is easier.
The Coupon Challenge on Paystack
On Stripe, you create a coupon, attach it to a subscription, and Stripe applies the discount automatically. On Paystack, none of this exists. You build the entire coupon system yourself.
Two approaches:
- Discounted plans: Create a separate Paystack plan at the promotional price. Subscribe promotional customers to this plan. Simple but creates plan sprawl.
- Application-managed coupons: Store coupons in your database, validate them at checkout, and use charge_authorization with the discounted amount. Flexible but requires your own billing engine.
This article is part of the subscriptions and recurring billing guide.
Approach 1: Discounted Plans
Create a Paystack plan at the discounted price and subscribe promotional customers to it.
// Create a promotional plan
async function createPromoPlan() {
var response = await axios.post(
'https://api.paystack.co/plan',
{
name: 'Pro Monthly - Launch Promo 50% Off (3 months)',
amount: 250000, // 2,500 NGN instead of 5,000 NGN
interval: 'monthly',
currency: 'NGN',
invoice_limit: 3, // Only 3 charges at this price
send_invoices: false,
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return response.data.data.plan_code;
}
// Subscribe customer to the promo plan
async function subscribeWithPromo(email, promoPlanCode) {
var response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: email,
amount: 250000,
plan: promoPlanCode,
callback_url: 'https://yoursite.com/billing/callback',
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return response.data.data.authorization_url;
}
When the invoice_limit is reached, the subscription completes. Listen for the subscription.disable webhook and migrate the customer to the full-price plan.
Pros: Simple. Paystack handles the billing. No custom billing engine needed.
Cons: Creates many plans. Each unique discount requires a separate plan. Hard to manage at scale.
Approach 2: Application-Managed Coupons
CREATE TABLE coupons (
id SERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
discount_type VARCHAR(20) NOT NULL, -- 'percentage' or 'fixed'
discount_value INTEGER NOT NULL,
max_redemptions INTEGER,
current_redemptions INTEGER DEFAULT 0,
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
applicable_plans TEXT[], -- NULL means all plans
duration_months INTEGER, -- How many months the discount lasts
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE coupon_redemptions (
id SERIAL PRIMARY KEY,
coupon_id INTEGER REFERENCES coupons(id),
user_id INTEGER REFERENCES users(id),
redeemed_at TIMESTAMPTZ DEFAULT NOW(),
months_remaining INTEGER NOT NULL
);
async function validateCoupon(code, planId) {
var coupon = await db.query(
'SELECT * FROM coupons WHERE code = $1 AND is_active = true',
[code.toUpperCase()]
);
if (coupon.rows.length === 0) {
return { valid: false, reason: 'Invalid coupon code' };
}
var c = coupon.rows[0];
// Check date validity
var now = new Date();
if (c.valid_from && new Date(c.valid_from) > now) {
return { valid: false, reason: 'This coupon is not yet active' };
}
if (c.valid_until && new Date(c.valid_until) < now) {
return { valid: false, reason: 'This coupon has expired' };
}
// Check redemption limit
if (c.max_redemptions && c.current_redemptions >= c.max_redemptions) {
return { valid: false, reason: 'This coupon has been fully redeemed' };
}
// Check plan applicability
if (c.applicable_plans && c.applicable_plans.indexOf(planId) === -1) {
return { valid: false, reason: 'This coupon does not apply to this plan' };
}
return {
valid: true,
coupon: c,
discountType: c.discount_type,
discountValue: c.discount_value,
durationMonths: c.duration_months,
};
}
function applyDiscount(originalAmount, coupon) {
if (coupon.discountType === 'percentage') {
var discount = Math.round(originalAmount * coupon.discountValue / 100);
return originalAmount - discount;
}
if (coupon.discountType === 'fixed') {
return Math.max(0, originalAmount - coupon.discountValue);
}
return originalAmount;
}
With application-managed coupons, you use charge_authorization instead of the Subscriptions API. This gives you full control over the amount but requires you to build the billing engine described in building your own billing engine.
Common Promotion Patterns
First month free
Collect card details during signup (see free trials). Start the subscription one month later. The customer gets a free month and then pays full price.
20% off for 3 months
With discounted plans: create a plan at 80% of the price with invoice_limit: 3. After 3 charges, migrate to the full-price plan.
With application coupons: apply the 20% discount in your billing calculator for 3 months, then charge full price from month 4.
Annual discount
This is not really a coupon but a pricing strategy. Create an annual plan priced lower per month than the monthly plan. See annual vs monthly billing.
Referral credit
When user A refers user B, give both a credit. Store the credit in your database and apply it to the next charge. This is best handled with application-managed credits rather than coupons.
Win-back discount
When a cancelled customer returns, offer them a discount on the first 1-2 months. Use either approach, but application-managed coupons are more flexible here because you can target specific users.
Migrating Customers After the Promotion Ends
// Listen for subscription completion (invoice_limit reached on promo plan)
async function handlePromoSubscriptionComplete(subscriptionCode) {
var billing = await db.query(
'SELECT user_id, plan_id FROM billing_accounts WHERE paystack_subscription_code = $1',
[subscriptionCode]
);
var userId = billing.rows[0].user_id;
var user = await getUser(userId);
var paymentMethod = await getDefaultPaymentMethod(userId);
if (!paymentMethod) {
await sendPromoEndedNoCardEmail(user.email);
return;
}
// Create subscription on the full-price plan
var fullPricePlanCode = getFullPricePlanCode(billing.rows[0].plan_id);
var authCode = await getAuthorizationCode(paymentMethod.id);
try {
var newSub = await createPaystackSubscription(user.email, fullPricePlanCode, authCode);
await db.query(
'UPDATE billing_accounts SET plan_id = (SELECT id FROM billing_plans WHERE paystack_plan_code = $1), paystack_subscription_code = $2, paystack_email_token = $3, updated_at = NOW() WHERE user_id = $4',
[fullPricePlanCode, newSub.subscription_code, newSub.email_token, userId]
);
await sendPromoEndedNewPriceEmail(user.email, fullPricePlanCode);
} catch (error) {
console.log('Failed to migrate user ' + userId + ' to full price: ' + error.message);
await sendPromoMigrationFailedEmail(user.email);
}
}
Notify the customer before the transition. Send an email 7 days before the promo ends: "Your promotional pricing ends on [date]. Starting next month, your plan will be [full price]." This reduces churn from price shock.
Tracking Coupon Performance
async function getCouponMetrics(couponId) {
var coupon = await db.query('SELECT * FROM coupons WHERE id = $1', [couponId]);
var c = coupon.rows[0];
var redemptions = await db.query(
'SELECT COUNT(*) as count FROM coupon_redemptions WHERE coupon_id = $1',
[couponId]
);
var stillActive = await db.query(
'SELECT COUNT(*) as count FROM coupon_redemptions cr JOIN billing_accounts ba ON cr.user_id = ba.user_id WHERE cr.coupon_id = $1 AND ba.status = $2',
[couponId, 'active']
);
var churned = await db.query(
'SELECT COUNT(*) as count FROM coupon_redemptions cr JOIN billing_accounts ba ON cr.user_id = ba.user_id WHERE cr.coupon_id = $1 AND ba.status IN ($2, $3)',
[couponId, 'cancelled', 'suspended']
);
return {
code: c.code,
totalRedemptions: parseInt(redemptions.rows[0].count),
stillActive: parseInt(stillActive.rows[0].count),
churned: parseInt(churned.rows[0].count),
retentionRate: parseInt(redemptions.rows[0].count) > 0
? (parseInt(stillActive.rows[0].count) / parseInt(redemptions.rows[0].count) * 100).toFixed(1) + '%'
: '0%',
};
}
The key metric is retention after the discount ends. If customers churn when full pricing kicks in, the promotion attracted price-sensitive users, not value-driven users. Adjust your promotion strategy based on this data.
Key Takeaways
- ✓Paystack has no native coupon or discount system. You build it yourself.
- ✓The simplest approach: create a discounted Paystack plan and subscribe promotional customers to it.
- ✓The flexible approach: manage coupons in your database and use charge_authorization with the discounted amount.
- ✓For limited-duration discounts, use the plan's invoice_limit to cap discounted charges, then migrate the customer to the full-price plan.
- ✓Track coupon usage, redemption rates, and the revenue impact of each promotion.
- ✓Always validate coupons server-side. Never trust a discount amount from the frontend.
Frequently Asked Questions
- Does Paystack have a coupon or discount API?
- No. Paystack does not have native coupon, discount, or promotional pricing features. You build coupon logic in your own application using either discounted plans or application-managed coupon codes with charge_authorization.
- Which approach is better: discounted plans or application-managed coupons?
- Discounted plans are simpler and work with the Subscriptions API. Use them for a small number of promotions. Application-managed coupons are more flexible and scalable but require a custom billing engine. Use them if you run frequent, varied promotions.
- How do I prevent coupon abuse?
- Limit redemptions per coupon (max_redemptions), limit to one redemption per user, set expiry dates, validate server-side (never trust the frontend), and track redemption patterns. Flag users who create multiple accounts to use the same coupon.
- Can I apply a coupon to an existing subscription?
- With the discounted plan approach, you need to cancel the current subscription and create a new one on the discounted plan. With application-managed coupons and charge_authorization, you apply the discount to the next charge without changing the subscription.
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