Bonaventure OgetoBy Bonaventure Ogeto|

Build a Logistics Quote and Payment System

Build a logistics payment system by generating a dynamic quote based on route and cargo specs, creating a Paystack payment link for the accepted quote, triggering dispatch via webhook when payment is confirmed, and paying the assigned courier via Paystack Transfers after delivery is confirmed.

Quote Generation and Payment Collection

Tables: zones (name, cities[]), pricing_matrix (origin_zone_id, dest_zone_id, base_rate, rate_per_kg), shipments (shipper_id, origin, destination, weight_kg, dimensions, quote_amount, status, paystack_ref, courier_id, tracking_number), couriers (name, phone, vehicle_type, paystack_recipient_code).

async function generateQuote(origin, destination, weightKg, packageType) {
  var originZone = await db.zones.findByCity(origin);
  var destZone = await db.zones.findByCity(destination);
  var pricing = await db.pricingMatrix.findByZones(originZone.id, destZone.id);

  var baseRate = pricing.base_rate;
  var weightCharge = Math.ceil(weightKg) * pricing.rate_per_kg;
  var total = baseRate + weightCharge;

  // Additional surcharges
  if (packageType === 'fragile') total = Math.floor(total * 1.15); // 15% fragile surcharge

  return { origin, destination, weight_kg: weightKg, quote_amount: total, expires_in_mins: 30 };
}

async function acceptQuoteAndPay(shipperEmail, shipperId, quoteData) {
  var trackingNumber = 'TRK-' + Date.now().toString(36).toUpperCase();
  var reference = 'LOG-' + Date.now();

  var shipment = await db.shipments.create({
    shipper_id: shipperId,
    origin: quoteData.origin,
    destination: quoteData.destination,
    weight_kg: quoteData.weight_kg,
    quote_amount: quoteData.quote_amount,
    status: 'pending_payment',
    paystack_ref: reference,
    tracking_number: trackingNumber,
  });

  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: shipperEmail,
      amount: quoteData.quote_amount,
      currency: 'NGN',
      reference,
      metadata: { shipment_id: shipment.id, shipper_id: shipperId, tracking_number: trackingNumber },
    }),
  });
  return { shipment, authorizationUrl: (await res.json()).data.authorization_url };
}

// Webhook: payment confirmed — dispatch job
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.shipment_id) return res.sendStatus(200);

  await db.shipments.update(meta.shipment_id, { status: 'awaiting_pickup' });
  var courier = await assignCourier(meta.shipment_id);
  await sendCourierJobNotification(courier.phone, meta.shipment_id, meta.tracking_number);
  await sendShipperConfirmation(meta.shipper_id, meta.tracking_number);
}

Shipment Tracking and Courier Payout

// Courier app updates shipment status
async function updateShipmentStatus(courierId, shipmentId, newStatus) {
  var validStatuses = ['picked_up', 'in_transit', 'delivered', 'failed_delivery'];
  if (!validStatuses.includes(newStatus)) throw new Error('Invalid status');

  var shipment = await db.shipments.findById(shipmentId);
  if (shipment.courier_id !== courierId) throw new Error('Not your shipment');

  await db.shipments.update(shipmentId, { status: newStatus, updated_at: new Date() });
  await notifyShipper(shipment.shipper_id, 'Your shipment ' + shipment.tracking_number + ' is now: ' + newStatus);

  // Pay courier on confirmed delivery
  if (newStatus === 'delivered') {
    var courier = await db.couriers.findById(courierId);
    var platformFee = Math.floor(shipment.quote_amount * 0.20); // 20% platform fee
    var courierPayout = shipment.quote_amount - platformFee;

    await fetch('https://api.paystack.co/transfer', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        source: 'balance', amount: courierPayout,
        recipient: courier.paystack_recipient_code,
        reason: 'Delivery payout: ' + shipment.tracking_number,
      }),
    });
  }
}

// Shipper can track their package
async function getTrackingInfo(trackingNumber) {
  var shipment = await db.shipments.findByTrackingNumber(trackingNumber);
  var history = await db.shipmentEvents.findByShipment(shipment.id);
  return { status: shipment.status, tracking_number: trackingNumber, events: history };
}

Learn More

See build a grocery delivery app with rider payouts for a related delivery model with item-based orders instead of shipment quotes.

Sign up for the McTaba newsletter

Key Takeaways

  • Generate quotes dynamically from weight, dimensions, and origin-destination zone matrix.
  • Payment triggers dispatch — couriers only receive the job after the customer pays.
  • Track shipment status updates (picked_up, in_transit, delivered) via courier app check-ins.
  • Pay couriers via Paystack Transfers after delivery is confirmed, not before.
  • Quote-to-dispatch conversion rate is your key business metric — optimize your pricing and UX around it.

Frequently Asked Questions

How do I handle failed deliveries (recipient not available)?
Define a re-delivery policy: one free re-delivery attempt within 24 hours. If the second attempt fails, the package returns to origin. Charge a return fee (or deduct it from the quote amount refund if the shipper requested the return). Mark the shipment as "returned" and notify the shipper via SMS. The courier receives partial pay for the failed delivery attempt.
How do I calculate quotes for same-day vs next-day delivery?
Add a delivery_type field (same_day, next_day, economy) with a multiplier per type. Same-day: 2x base rate; next-day: 1.3x; economy: 1x. Apply the multiplier after the zone-based calculation. Same-day orders also need a time cutoff (e.g., orders before 12:00 get same-day, after 12:00 are next-day).
What happens if the customer pays but no courier accepts the job?
Set a maximum assignment timeout (30 minutes). If no courier accepts within the timeout, cancel the job and issue a full refund via the Paystack Refund API. Notify the shipper with an apology and an option to retry. For high-demand routes, pre-assign couriers rather than broadcasting jobs — this prevents the "no courier available" scenario for popular routes.

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