Bonaventure OgetoBy Bonaventure Ogeto|

Gym and Class Membership Billing with Paystack

Use the Paystack Subscriptions API for standard monthly gym memberships. Implement freeze and resume in your application (Paystack has no native pause). For class packs and drop-in sessions, use one-time charges or charge_authorization. Tie billing status to your access control system so expired memberships automatically restrict entry.

Mapping Membership Types to Paystack

Gym businesses typically offer several membership types, each mapping differently to Paystack:

Membership TypePaystack Approach
Monthly unlimitedSubscriptions API (fixed amount, monthly interval)
Annual membershipSubscriptions API (fixed amount, annual interval)
Class pack (10 classes)One-time checkout charge. Track remaining classes in your database.
Drop-in / day passOne-time checkout charge per visit.
Family planSubscriptions API at the family rate. Track individual family members.
Corporate planSubscriptions API or manual invoice for the company.

This article is part of the subscriptions and recurring billing guide.

Setting Up Monthly Memberships

var MEMBERSHIP_PLANS = {
  basic: { name: 'Basic Gym Access', planCode: 'PLN_gym_basic_xxx', amount: 1500000 },
  premium: { name: 'Premium (Gym + Classes)', planCode: 'PLN_gym_premium_xxx', amount: 2500000 },
  vip: { name: 'VIP (All Access + PT)', planCode: 'PLN_gym_vip_xxx', amount: 5000000 },
};

async function subscribeToMembership(memberEmail, membershipType) {
  var plan = MEMBERSHIP_PLANS[membershipType];

  var response = await axios.post(
    'https://api.paystack.co/transaction/initialize',
    {
      email: memberEmail,
      amount: plan.amount,
      plan: plan.planCode,
      callback_url: 'https://yourgym.com/membership/callback',
    },
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return response.data.data.authorization_url;
}

When the payment completes, the webhook creates the subscription and you activate the membership in your system. Link the billing status to the member's profile so your front-desk software and access control system know whether to grant entry.

Implementing Membership Freeze

var FREEZE_POLICY = {
  maxFreezeDays: 30,
  maxFreezesPerYear: 2,
  minimumActiveBeforeFreeze: 30,
};

async function freezeMembership(userId, freezeDays, reason) {
  if (freezeDays > FREEZE_POLICY.maxFreezeDays) {
    return { success: false, message: 'Maximum freeze duration is ' + FREEZE_POLICY.maxFreezeDays + ' days.' };
  }

  var billing = await getCurrentBilling(userId);

  // Cancel on Paystack
  await disableSubscription(billing.paystack_subscription_code, billing.paystack_email_token);

  var resumeDate = new Date();
  resumeDate.setDate(resumeDate.getDate() + freezeDays);

  await db.query(
    'UPDATE memberships SET status = $1, frozen_at = NOW(), resume_date = $2, freeze_reason = $3, updated_at = NOW() WHERE user_id = $4',
    ['frozen', resumeDate, reason, userId]
  );

  // Update access control
  await updateAccessControl(userId, 'deny');

  return {
    success: true,
    frozenUntil: resumeDate,
    message: 'Membership frozen until ' + formatDate(resumeDate) + '.',
  };
}

// Daily cron: auto-resume frozen memberships
async function autoResumeFrozenMemberships() {
  var toResume = await db.query(
    'SELECT m.user_id, m.membership_type, u.email FROM memberships m JOIN users u ON m.user_id = u.id WHERE m.status = $1 AND m.resume_date <= CURRENT_DATE',
    ['frozen']
  );

  for (var i = 0; i < toResume.rows.length; i++) {
    var member = toResume.rows[i];
    var paymentMethod = await getDefaultPaymentMethod(member.user_id);

    if (!paymentMethod) {
      await sendResumeFailedEmail(member.email, 'No card on file');
      continue;
    }

    var plan = MEMBERSHIP_PLANS[member.membership_type];
    var authCode = await getAuthorizationCode(paymentMethod.id);

    try {
      var newSub = await createPaystackSubscription(member.email, plan.planCode, authCode);

      await db.query(
        'UPDATE memberships SET status = $1, frozen_at = NULL, resume_date = NULL, updated_at = NOW() WHERE user_id = $2',
        ['active', member.user_id]
      );

      await updateAccessControl(member.user_id, 'allow');
      await sendMembershipResumedEmail(member.email);
    } catch (error) {
      console.log('Resume failed for ' + member.user_id + ': ' + error.message);
    }
  }
}

Class Pack Billing

var CLASS_PACKS = {
  pack_5: { name: '5 Class Pack', classes: 5, amount: 500000 },
  pack_10: { name: '10 Class Pack', classes: 10, amount: 900000 },
  pack_20: { name: '20 Class Pack', classes: 20, amount: 1600000 },
};

async function purchaseClassPack(userId, packType) {
  var pack = CLASS_PACKS[packType];
  var user = await getUser(userId);

  var response = await axios.post(
    'https://api.paystack.co/transaction/initialize',
    {
      email: user.email,
      amount: pack.amount,
      callback_url: 'https://yourgym.com/classes/pack-purchased',
      metadata: { pack_type: packType, user_id: userId },
    },
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return response.data.data.authorization_url;
}

// After successful payment
async function activateClassPack(userId, packType) {
  var pack = CLASS_PACKS[packType];

  await db.query(
    'INSERT INTO class_packs (user_id, pack_type, total_classes, remaining_classes, purchased_at, expires_at) VALUES ($1, $2, $3, $4, NOW(), NOW() + INTERVAL '90 days')',
    [userId, packType, pack.classes, pack.classes]
  );
}

// When member checks into a class
async function deductClassFromPack(userId) {
  var activePack = await db.query(
    'SELECT id, remaining_classes FROM class_packs WHERE user_id = $1 AND remaining_classes > 0 AND expires_at > NOW() ORDER BY purchased_at ASC LIMIT 1',
    [userId]
  );

  if (activePack.rows.length === 0) {
    return { success: false, message: 'No active class pack. Please purchase one.' };
  }

  await db.query(
    'UPDATE class_packs SET remaining_classes = remaining_classes - 1 WHERE id = $1',
    [activePack.rows[0].id]
  );

  return {
    success: true,
    remaining: activePack.rows[0].remaining_classes - 1,
  };
}

Tying Billing to Access Control

// API for your access control system (door scanner, front desk app)
router.get('/api/access/check/:memberId', async function(req, res) {
  var userId = req.params.memberId;

  var membership = await db.query(
    'SELECT status FROM memberships WHERE user_id = $1',
    [userId]
  );

  if (membership.rows.length === 0) {
    return res.json({ access: false, reason: 'No membership found' });
  }

  var status = membership.rows[0].status;

  if (status === 'active') {
    return res.json({ access: true });
  }

  if (status === 'frozen') {
    return res.json({ access: false, reason: 'Membership is frozen' });
  }

  if (status === 'past_due') {
    // Grace period: allow access for 3 days after payment failure
    var billing = await getCurrentBilling(userId);
    var daysSinceFailure = Math.floor(
      (Date.now() - new Date(billing.status_changed_at).getTime()) / (24 * 60 * 60 * 1000)
    );

    if (daysSinceFailure <= 3) {
      return res.json({ access: true, warning: 'Payment overdue. Please update your card.' });
    }
    return res.json({ access: false, reason: 'Payment required' });
  }

  res.json({ access: false, reason: 'Membership is ' + status });
});

Real-time access checks ensure that billing status directly controls physical access. When a membership payment fails, the member can still enter during the grace period but is prompted to update their payment. After the grace period, the door scanner denies entry.

Family Plans

Family plans charge one card for multiple members. One Paystack subscription covers the whole family.

async function createFamilyPlan(primaryMemberEmail, familyMembers) {
  // Subscribe the primary member to the family plan
  var familyPlanCode = 'PLN_gym_family_xxx'; // e.g., 35,000 NGN/month for up to 4 members

  var checkoutUrl = await subscribeToMembership(primaryMemberEmail, familyPlanCode);

  // Store family members linked to the primary account
  for (var i = 0; i < familyMembers.length; i++) {
    await db.query(
      'INSERT INTO family_members (primary_user_id, member_name, relationship, created_at) VALUES ((SELECT id FROM users WHERE email = $1), $2, $3, NOW())',
      [primaryMemberEmail, familyMembers[i].name, familyMembers[i].relationship]
    );
  }

  return checkoutUrl;
}

All family members get access as long as the primary member's subscription is active. When the primary member's payment fails or membership is frozen, all family members lose access too. Make this policy clear during signup.

Key Takeaways

  • The Subscriptions API works well for standard monthly gym memberships with fixed fees.
  • Implement membership freeze in your application by cancelling the subscription and storing the authorization for later resume.
  • Class packs (10 classes, 20 classes) are one-time purchases, not subscriptions. Use standard Paystack checkout.
  • Tie billing status to your access control system. When a membership expires or is frozen, entry should be denied automatically.
  • Family plans charge one card for multiple members. Use one authorization code and one subscription for the family.
  • The biggest billing challenge for gyms is the freeze/resume cycle. Build clear policies and communicate them well.

Frequently Asked Questions

Should I use the Subscriptions API or charge_authorization for gym memberships?
Use the Subscriptions API for standard monthly or annual memberships with fixed fees. It handles scheduling and renewals automatically. Use charge_authorization for class packs, variable-amount billing, or if you need custom retry logic.
How do I handle membership freezing if Paystack has no pause feature?
Cancel the subscription on Paystack, mark the membership as frozen in your database, and store the resume date. When the freeze period ends, create a new subscription using the stored authorization code. The billing date resets to the resume date.
What happens if a frozen member's card expires during the freeze?
When the freeze ends and you try to create a new subscription, the authorization code will fail. Notify the member that their card has expired and ask them to make a new payment to resume their membership.
How do I handle class pack expiry?
Set an expires_at date when the pack is purchased (typically 60-90 days). When checking in for a class, verify that the pack has remaining classes AND has not expired. Expired classes are forfeit according to your terms.

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