Bonaventure OgetoBy Bonaventure Ogeto|

Build a Laundry Pickup and Payment App

Build a laundry pickup app by creating a service catalog with per-item or per-kg pricing, letting customers schedule pickups, calculating totals on your backend, and collecting payment through Paystack at order time or on delivery. Use webhook confirmation to advance orders from "pending payment" to "scheduled for pickup." Track each order through pickup, washing, drying, ironing, and delivery stages with status updates.

Architecture Overview

A laundry pickup app connects customers who need laundry done with a laundry service that picks up, washes, and delivers. The payment happens when the customer places the order. The fulfillment (pickup, wash, delivery) happens over the next 24-48 hours.

The flow works like this:

  1. Customer selects laundry items or enters a weight estimate
  2. Your backend calculates the total based on your price list
  3. Customer selects a pickup time slot and enters their address
  4. Your backend creates an order and initializes a Paystack transaction
  5. Customer pays through Paystack checkout
  6. Webhook confirms payment. Order moves to "pickup_scheduled"
  7. A pickup rider collects the laundry at the scheduled time
  8. The laundry is washed, dried, and ironed
  9. A delivery rider returns the clean laundry
  10. Order status moves to "delivered"

One key design choice: the initial price is an estimate based on what the customer says they have. After pickup, the laundry service weighs the actual load or counts the actual items. If the real price differs from the estimate, you have two options: absorb the difference (simpler) or charge an additional amount (requires a follow-up transaction). Most laundry apps absorb small differences and only charge extra for significant discrepancies.

Data Model

You need four tables: service_items, orders, order_items, and pickup_slots.

The service_items table is your price list:

  • name: "Shirt," "Trousers," "Bedsheet," "Per KG Mixed"
  • price: In smallest currency unit
  • category: wash_fold, wash_iron, dry_clean, per_kg

The orders table tracks each laundry order through its lifecycle:

  • customer_id, customer_phone: For notifications
  • estimated_total: What the customer was charged at order time
  • actual_total: Filled in after pickup when items are counted (nullable)
  • pickup_address: JSON with address and coordinates
  • pickup_slot_id: When to pick up
  • status: placed, paid, pickup_scheduled, picked_up, washing, ready, out_for_delivery, delivered
  • paystack_reference: Transaction reference
  • notes: Customer instructions ("Handle the silk blouse carefully")

The pickup_slots table defines available time windows. Each slot has a date, start_time, end_time, capacity (how many pickups per slot), and booked_count. When booked_count equals capacity, the slot shows as unavailable.

Pricing Calculation and Checkout

var axios = require("axios");

async function createLaundryOrder(customerId, items, pickupSlotId, address, notes) {
  // Calculate estimated total
  var total = 0;
  var itemDetails = [];

  for (var i = 0; i < items.length; i++) {
    var service = await db.query(
      "SELECT * FROM service_items WHERE id = $1",
      [items[i].service_item_id]
    );
    var lineTotal = service.price * items[i].quantity;
    total = total + lineTotal;
    itemDetails.push({
      name: service.name,
      quantity: items[i].quantity,
      unit_price: service.price,
      line_total: lineTotal
    });
  }

  // Add pickup/delivery fee
  var deliveryFee = 20000; // KES 200 in cents
  total = total + deliveryFee;

  // Verify pickup slot is available
  var slot = await db.query("SELECT * FROM pickup_slots WHERE id = $1", [pickupSlotId]);
  if (slot.booked_count >= slot.capacity) {
    throw new Error("This pickup slot is full. Choose another time.");
  }

  var customer = await db.query("SELECT * FROM customers WHERE id = $1", [customerId]);
  var reference = "LAUNDRY-" + Date.now();

  var order = await db.query(
    "INSERT INTO orders (customer_id, customer_phone, estimated_total, pickup_address, pickup_slot_id, status, paystack_reference, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
    [customerId, customer.phone, total, JSON.stringify(address), pickupSlotId, "placed", reference, notes]
  );

  var response = await axios.post(
    "https://api.paystack.co/transaction/initialize",
    {
      email: customer.email,
      amount: total,
      currency: "KES",
      reference: reference,
      metadata: {
        order_id: order.id,
        items: itemDetails,
        delivery_fee: deliveryFee,
        pickup_date: slot.date,
        pickup_time: slot.start_time + " - " + slot.end_time
      }
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  return response.data.data.authorization_url;
}

The delivery fee can be flat or distance-based. For a single-area service (one neighborhood in Nairobi), a flat fee works. For a city-wide service, calculate based on distance between the customer and your nearest laundry facility.

Webhook Handling and Pickup Scheduling

var crypto = require("crypto");

router.post("/webhooks/paystack", express.raw({ type: "application/json" }), async function(req, res) {
  var hash = crypto
    .createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
    .update(req.body)
    .digest("hex");

  if (hash !== req.headers["x-paystack-signature"]) {
    return res.status(401).send("Invalid signature");
  }

  var event = JSON.parse(req.body);

  if (event.event === "charge.success") {
    var meta = event.data.metadata;
    var order = await db.query("SELECT * FROM orders WHERE id = $1", [meta.order_id]);

    if (!order || order.status !== "placed") {
      return res.status(200).send("OK");
    }

    // Update order status
    await db.query(
      "UPDATE orders SET status = $1 WHERE id = $2",
      ["pickup_scheduled", order.id]
    );

    // Book the pickup slot
    await db.query(
      "UPDATE pickup_slots SET booked_count = booked_count + 1 WHERE id = $1",
      [order.pickup_slot_id]
    );

    // Notify customer via SMS
    await sendSMS(
      order.customer_phone,
      "Your laundry order #" + order.id.slice(0, 8) + " is confirmed. Pickup: " + meta.pickup_date + " " + meta.pickup_time
    );

    // Notify pickup rider
    await assignPickupRider(order);
  }

  res.status(200).send("OK");
});

After payment confirmation, the system books the pickup slot and notifies both the customer and the assigned pickup rider. The rider sees the customer address, pickup time, and any special notes.

Build a simple admin panel where staff can update order status as the laundry moves through stages. When status changes, the customer gets an SMS: "Your laundry has been picked up," "Your laundry is being washed," "Your laundry is ready for delivery," "Your laundry has been delivered."

Handling Price Adjustments After Pickup

If the actual item count or weight differs from the estimate, you have a decision to make. The simplest approach is to set a tolerance threshold (say, 10% above estimate) and absorb differences within that range. For larger discrepancies, contact the customer before proceeding.

async function adjustOrderTotal(orderId, actualItems) {
  var actualTotal = actualItems.reduce(function(sum, item) {
    return sum + (item.price * item.quantity);
  }, 0);

  var order = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);
  var difference = actualTotal - order.estimated_total;
  var threshold = order.estimated_total * 0.1; // 10% tolerance

  if (difference <= 0) {
    // Actual is less than estimate - no action needed, customer already paid more
    await db.query(
      "UPDATE orders SET actual_total = $1 WHERE id = $2",
      [actualTotal, orderId]
    );
  } else if (difference <= threshold) {
    // Small overage - absorb it
    await db.query(
      "UPDATE orders SET actual_total = $1 WHERE id = $2",
      [actualTotal, orderId]
    );
  } else {
    // Large overage - notify customer and request additional payment
    await db.query(
      "UPDATE orders SET actual_total = $1, status = $2 WHERE id = $3",
      [actualTotal, "pending_adjustment", orderId]
    );
    await sendSMS(
      order.customer_phone,
      "Your laundry has more items than estimated. Additional charge: KES " + (difference / 100) + ". Reply YES to confirm."
    );
  }
}

If the customer rejects the additional charge, you can proceed with washing at the estimated price (absorbing the loss) or return the excess items unwashed. Most laundry services choose to wash everything and absorb small losses to maintain customer satisfaction.

Multi-Partner Operations with Subaccounts

If your platform works with multiple laundry partners (different facilities in different areas), use Paystack subaccounts to route payments:

async function initializeWithPartner(order, partner) {
  var response = await axios.post(
    "https://api.paystack.co/transaction/initialize",
    {
      email: order.customer_email,
      amount: order.estimated_total,
      currency: "KES",
      reference: order.paystack_reference,
      subaccount: partner.paystack_subaccount_code,
      bearer: "account",
      metadata: {
        order_id: order.id,
        partner_id: partner.id
      }
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  return response.data.data.authorization_url;
}

The partner gets their share automatically at settlement. Your platform keeps the commission. This scales well because adding a new laundry partner is just creating a subaccount and adding their service area to your system.

Recurring Weekly Pickups

Many customers want weekly laundry pickup on the same day. Build a recurring order feature using Paystack Charge Authorization to charge their saved card each week:

When a customer sets up a recurring order, save their authorization_code from the first successful payment. Then each week, charge their card automatically using the Charge Authorization endpoint with the saved code, the recurring amount, and their email.

If the weekly charge fails (card expired, insufficient funds), send an SMS notification and retry once the next day. After two failed attempts, pause the recurring order and ask the customer to update their payment method.

This feature is a strong retention tool. Customers who set up weekly pickups generate predictable revenue and have high lifetime value. Offer a small discount (5-10%) for weekly subscriptions to incentivize sign-ups.

Deployment and Testing

Test the full order lifecycle: place an order with test payment, verify the pickup scheduling, walk the order through each status, and verify the customer receives SMS notifications at each stage.

Test edge cases: a customer ordering at the last available pickup slot (verify slot capacity enforcement), a price adjustment after pickup, and a cancellation request after payment but before pickup.

Deploy with a publicly accessible webhook URL. Set up monitoring on the webhook endpoint and the SMS delivery service. If either fails, orders will be paid but not scheduled, which is a terrible customer experience.

For your first launch, start in a single neighborhood. Get the logistics right (pickup timing, wash quality, delivery reliability) before expanding to more areas. Payment integration is the easy part. Logistics is where laundry apps succeed or fail.

Key Takeaways

  • Offer both per-item pricing (shirts, trousers, bedsheets) and per-kg pricing for mixed loads. Let the customer choose which model suits them.
  • Collect payment at order time, not at delivery. This eliminates the risk of washing someone's clothes and them refusing to pay.
  • Track orders through clearly defined stages: placed, paid, pickup_scheduled, picked_up, washing, ready, out_for_delivery, delivered. Each stage gets a timestamp.
  • Use Paystack metadata to store the order breakdown (items, quantities, prices). This makes dispute resolution straightforward.
  • Build SMS or WhatsApp notifications for status updates. Most laundry customers in African markets prefer SMS over email.
  • If you operate with multiple laundry partners, use Paystack subaccounts to route payments to the right partner with your commission deducted.

Frequently Asked Questions

Should I charge before pickup or after delivery?
Charge before pickup. If you charge after delivery, you risk washing clothes for customers who refuse to pay or whose payment fails. Prepayment also simplifies your cash flow and eliminates the need for riders to collect cash.
How do I handle damaged or lost items?
Have a clear compensation policy. If an item is damaged during washing, offer a refund for that item using the Paystack Refund API (partial refund). For lost items, refund the item value. Track all incidents in your database with photos and resolution notes. Good documentation protects you against inflated claims.
Can I offer express service at a higher price?
Yes. Create separate service items for express (same-day or next-day) with higher prices. The payment flow is identical; only the price and the expected delivery timeline change. Show express and standard options side by side so customers can see the time-price tradeoff.
How do I handle customers who are not home during the pickup window?
Offer two options: the customer can leave the laundry bag at a designated spot (outside the door, with a guard), or reschedule the pickup. If the rider arrives and nobody is home and no bag is left, mark the order as "pickup_failed" and offer to reschedule. Do not refund automatically since the pickup slot was reserved.

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