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.
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:
This article is part of the subscriptions and recurring billing guide.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
// 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.
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course