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.
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