Bonaventure OgetoBy Bonaventure Ogeto|

Build a Water Delivery Subscription Service

Build a water delivery subscription by creating Paystack Plans per delivery frequency (weekly, biweekly, monthly), subscribing customers via the transaction initialize endpoint, triggering a delivery schedule on each invoice.create success webhook, and pausing deliveries when invoice.payment_failed fires until the customer updates their payment method.

Subscription Plans and Customer Enrollment

Tables: delivery_plans (name, jerrycans_per_cycle, interval, paystack_plan_code, price), customers (name, phone, address, delivery_zone, paystack_subscription_code, status, next_delivery_date), deliveries (customer_id, scheduled_date, driver_id, status, jerrycans_delivered, empties_collected).

var DELIVERY_PLANS = {
  weekly:   { cans: 2, price: 100000, label: '2 cans weekly' },     // NGN 1,000/week
  biweekly: { cans: 4, price: 180000, label: '4 cans biweekly' },   // NGN 1,800/biweek
  monthly:  { cans: 8, price: 320000, label: '8 cans monthly' },    // NGN 3,200/month
};

async function enrollCustomer(customerEmail, customerId, planName) {
  var plan = await db.deliveryPlans.findByName(planName);

  var res = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: customerEmail,
      amount: plan.price,
      plan: plan.paystack_plan_code,
      metadata: { customer_id: customerId, plan_id: plan.id },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// First subscription created
if (event.event === 'subscription.create') {
  var meta = event.data.metadata;
  await db.customers.update(meta.customer_id, {
    paystack_subscription_code: event.data.subscription_code,
    status: 'active',
  });
}

// Each billing cycle — schedule the next delivery
if (event.event === 'invoice.create' && event.data.status === 'success') {
  var code = event.data.subscription.subscription_code;
  var customer = await db.customers.findBySubscription(code);
  var plan = await db.deliveryPlans.findById(customer.plan_id);

  // Schedule delivery for next available delivery day in the customer's zone
  var deliveryDate = await nextDeliveryDayForZone(customer.delivery_zone);
  await db.deliveries.create({
    customer_id: customer.id,
    scheduled_date: deliveryDate,
    status: 'scheduled',
    jerrycans_to_deliver: plan.cans_per_cycle,
  });
  await sendSMS(customer.phone, 'Your water delivery is scheduled for ' + deliveryDate.toDateString() + '. Please have your empty cans ready.');
}

Delivery Management and Failed Payment Handling

// Driver confirms delivery
async function confirmDelivery(driverId, deliveryId, emptiesCollected) {
  await db.deliveries.update(deliveryId, {
    status: 'delivered',
    driver_id: driverId,
    delivered_at: new Date(),
    empties_collected: emptiesCollected,
  });
  var delivery = await db.deliveries.findById(deliveryId);
  await sendDeliveryReceipt(delivery.customer_id, emptiesCollected);
}

// Failed payment — pause deliveries
if (event.event === 'invoice.payment_failed') {
  var code = event.data.subscription.subscription_code;
  var customer = await db.customers.findBySubscription(code);

  await db.customers.update(customer.id, { status: 'payment_failed' });
  // Cancel any upcoming deliveries
  await db.deliveries.cancelUpcoming(customer.id);
  await sendSMS(customer.phone,
    'Your water delivery is paused — payment failed. Please pay your subscription to resume deliveries: ' +
    'https://yourapp.com/account/billing'
  );
}

// Subscription resumed (customer pays outstanding invoice)
if (event.event === 'invoice.create' && event.data.status === 'success') {
  // Runs for both regular renewals and outstanding invoice payments
  var customer = await db.customers.findBySubscription(event.data.subscription.subscription_code);
  if (customer.status === 'payment_failed') {
    await db.customers.update(customer.id, { status: 'active' });
    // Re-schedule missed deliveries or just resume from next cycle
    await scheduleNextDelivery(customer.id);
  }
}

// Zone-based route grouping for drivers
async function buildDailyRoute(zone, date) {
  var deliveries = await db.deliveries.findByZoneAndDate(zone, date);
  deliveries.sort(function(a, b) { return a.customer.address_order - b.customer.address_order; });
  return deliveries;
}

Learn More

See build a subscription box business backend for the same Paystack Plans subscription model applied to physical product deliveries.

Sign up for the McTaba newsletter

Key Takeaways

  • Use Paystack Plans for recurring billing — match plan intervals to delivery frequency (weekly, monthly).
  • Schedule the next delivery in the invoice.create success webhook, not subscription.create.
  • Pause deliveries on invoice.payment_failed — resume when the customer pays the outstanding invoice.
  • Group deliveries by zone and day to optimize driver routes and reduce operational cost.
  • Track empty jerry can returns — deliveries depend on the driver picking up empties from the previous cycle.

Frequently Asked Questions

How do I handle customers who want to skip a delivery week?
Add a "Skip next delivery" option in the customer portal. This does not pause the subscription — billing continues. Instead, mark the next delivery as "skipped" and do not create a delivery record for that cycle. Some services offer a maximum of 2 skips per year to prevent revenue loss. Communicate the skip deadline clearly (e.g., skip before Monday 5 PM for the following week's delivery).
How do I manage the empty jerry can return system?
Track empties_on_hand per customer. When a delivery goes out, add cans delivered to the customer's total. When a driver collects empties, deduct from the total. If a customer accumulates more than 2 unreturned cans above their subscription quantity, charge a deposit (NGN 500 per can) via a one-time Paystack payment link. This incentivizes returns without blocking deliveries.
Can I support on-demand orders (not on a subscription)?
Yes. Build a one-time order flow alongside the subscription: customer selects quantity, pays via Paystack standard checkout, delivery is scheduled in the webhook. Offer a discount on subscriptions vs one-time orders to encourage recurring revenue. Track the conversion rate from one-time orders to subscriptions — target 30%+ of one-time customers converting to subscriptions within 60 days.

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