Bonaventure OgetoBy Bonaventure Ogeto|

Build a Restaurant Ordering and Payment App

Build a restaurant ordering app by creating a menu catalog, cart system, and order pipeline backed by Paystack split payments. When a customer pays, Paystack routes the restaurant share to a subaccount, your platform keeps a commission, and you pay the rider separately via the Transfers API after delivery confirmation. Use webhooks to drive the order through its lifecycle from placed to delivered.

Architecture Overview

A restaurant ordering app has three user types: the customer who orders food, the restaurant that prepares it, and the delivery rider who brings it. Money flows from the customer through your platform, then splits between the restaurant and your commission. The rider gets paid separately after completing the delivery.

The system works in this sequence:

  1. Customer browses a restaurant menu, adds items to cart, and places an order
  2. Your backend creates an order record and initializes a Paystack transaction with a split payment configuration
  3. Customer pays through Paystack checkout (card, M-Pesa, or bank transfer)
  4. Paystack webhook confirms payment. Your backend updates the order to "confirmed" and notifies the restaurant
  5. Restaurant confirms and prepares the food. Status moves to "preparing" then "ready"
  6. A rider is assigned and picks up the order. Status moves to "picked_up"
  7. Rider delivers the food. Status moves to "delivered"
  8. Your backend triggers a Paystack Transfer to pay the rider

The tech stack is Node.js with Express, PostgreSQL for the database, and Redis for real-time order status updates. The Paystack integration involves three API surfaces: Initialize Transaction with split configuration, webhooks for payment confirmation, and Transfers for rider payouts.

One important design decision: the restaurant gets paid through Paystack subaccounts (automatic settlement), but the rider gets paid through Transfers (manual disbursement). This separation exists because the restaurant share is fixed at order time, but the rider payout might change if there is a tip, a distance surcharge, or a delivery issue.

Data Model

You need six core tables: restaurants, menu_items, customers, orders, order_items, and riders.

The restaurants table stores each restaurant along with its Paystack subaccount code. When you onboard a restaurant, you create a subaccount on Paystack using their bank details, and store the returned subaccount_code in this table. Every payment to that restaurant will reference this code.

The menu_items table holds each dish with its name, description, price (in kobo or cents), category, availability flag, and preparation time estimate. Prices in the smallest currency unit from the start.

The orders table tracks each order through its lifecycle:

  • id: UUID
  • customer_id: Who placed the order
  • restaurant_id: Which restaurant fulfills it
  • rider_id: Assigned after the food is ready (nullable until then)
  • subtotal: Sum of item prices in smallest currency unit
  • delivery_fee: Calculated based on distance
  • platform_fee: Your commission
  • total_amount: What the customer pays
  • status: placed, confirmed, preparing, ready, picked_up, delivered, cancelled
  • paystack_reference: Transaction reference for reconciliation
  • rider_transfer_reference: Paystack Transfer reference for rider payout
  • delivery_address: JSON with lat, lng, and formatted address

The order_items table captures each item in the order with the quantity and the unit_price at the time of ordering. Always snapshot the price. If the restaurant changes their jollof price tomorrow, it should not affect yesterday's orders.

The riders table stores rider details and their Paystack transfer recipient code. You create a Transfer Recipient on Paystack using the rider's bank account or mobile money number, then store the recipient_code for future payouts.

Restaurant Onboarding with Subaccounts

Before a restaurant can receive payments, you need to create a Paystack subaccount for them. This is a one-time setup per restaurant:

var axios = require("axios");

async function onboardRestaurant(restaurant) {
  var response = await axios.post(
    "https://api.paystack.co/subaccount",
    {
      business_name: restaurant.name,
      bank_code: restaurant.bank_code,
      account_number: restaurant.account_number,
      percentage_charge: 85 // Restaurant gets 85% of subtotal
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  var subaccountCode = response.data.data.subaccount_code;

  await db.query(
    "UPDATE restaurants SET paystack_subaccount_code = $1 WHERE id = $2",
    [subaccountCode, restaurant.id]
  );

  return subaccountCode;
}

The percentage_charge determines how much of each transaction goes to the restaurant. The remainder stays in your main Paystack account. Adjust this based on your commission model. If you charge a 15% commission, set percentage_charge to 85.

For rider onboarding, create a Transfer Recipient instead:

async function onboardRider(rider) {
  var response = await axios.post(
    "https://api.paystack.co/transferrecipient",
    {
      type: "mobile_money",
      name: rider.name,
      account_number: rider.phone_number,
      bank_code: "MPS", // M-Pesa code for Kenya
      currency: "KES"
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  await db.query(
    "UPDATE riders SET paystack_recipient_code = $1 WHERE id = $2",
    [response.data.data.recipient_code, rider.id]
  );
}

The rider recipient is used later when you initiate the delivery payout after the order is delivered.

Checkout with Split Payment

When the customer confirms their order, your backend calculates the total and initializes a Paystack transaction with split payment configuration. The split tells Paystack to route the restaurant share to their subaccount automatically:

async function createOrderAndPay(customerId, cartItems, restaurantId, deliveryAddress) {
  // Calculate totals
  var subtotal = cartItems.reduce(function(sum, item) {
    return sum + (item.unit_price * item.quantity);
  }, 0);
  var deliveryFee = calculateDeliveryFee(deliveryAddress);
  var totalAmount = subtotal + deliveryFee;

  // Create order in database
  var order = await db.query(
    "INSERT INTO orders (customer_id, restaurant_id, subtotal, delivery_fee, total_amount, status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
    [customerId, restaurantId, subtotal, deliveryFee, totalAmount, "placed"]
  );

  // Get restaurant subaccount
  var restaurant = await db.query(
    "SELECT paystack_subaccount_code FROM restaurants WHERE id = $1",
    [restaurantId]
  );

  // Initialize Paystack transaction with split
  var customer = await db.query("SELECT email FROM customers WHERE id = $1", [customerId]);

  var response = await axios.post(
    "https://api.paystack.co/transaction/initialize",
    {
      email: customer.email,
      amount: totalAmount,
      currency: "KES",
      reference: "ORD-" + order.id + "-" + Date.now(),
      subaccount: restaurant.paystack_subaccount_code,
      bearer: "account",
      metadata: {
        order_id: order.id,
        restaurant_id: restaurantId,
        items: cartItems.map(function(item) {
          return { name: item.name, qty: item.quantity, price: item.unit_price };
        })
      }
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  await db.query(
    "UPDATE orders SET paystack_reference = $1 WHERE id = $2",
    [response.data.data.reference, order.id]
  );

  return response.data.data.authorization_url;
}

The subaccount parameter tells Paystack to split the payment. The bearer parameter set to "account" means your platform account bears the Paystack transaction fees. If you set it to "subaccount," the restaurant bears the fees. Choose based on your business model.

Note that the delivery fee stays with your platform since the restaurant subaccount only gets its configured percentage of the transaction. You will use the delivery fee portion to pay the rider later.

Webhook Handling and Order Lifecycle

When payment succeeds, Paystack sends a charge.success webhook. Your handler verifies the signature, finds the order, and advances its status:

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 orderId = event.data.metadata.order_id;
    var order = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);

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

    // Verify amount matches
    if (event.data.amount !== order.total_amount) {
      await db.query("UPDATE orders SET status = $1 WHERE id = $2", ["amount_mismatch", orderId]);
      return res.status(200).send("OK");
    }

    // Advance to confirmed
    await db.query(
      "UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2",
      ["confirmed", orderId]
    );

    // Notify restaurant (push notification, SMS, or dashboard alert)
    await notifyRestaurant(order.restaurant_id, orderId);
  }

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

From here, the restaurant interacts with your system through an admin dashboard or a simple API. When they start preparing, they call an endpoint that updates status to "preparing." When the food is ready, another call sets it to "ready" and triggers rider assignment.

Each status transition should be logged in a separate order_status_history table with a timestamp. This gives you a complete timeline of every order for debugging and customer support. "Your food was confirmed at 12:03, started preparing at 12:05, ready at 12:22, picked up at 12:28, delivered at 12:41."

Rider Assignment and Payout

When the food is ready, your system assigns a rider and updates the order. After the rider delivers the food, you pay them using the Paystack Transfers API:

async function payRider(orderId) {
  var order = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);

  if (order.status !== "delivered") {
    throw new Error("Order must be delivered before paying rider");
  }

  var rider = await db.query("SELECT * FROM riders WHERE id = $1", [order.rider_id]);

  var response = await axios.post(
    "https://api.paystack.co/transfer",
    {
      source: "balance",
      amount: order.delivery_fee,
      recipient: rider.paystack_recipient_code,
      reason: "Delivery payout for order " + orderId,
      reference: "RIDER-" + orderId + "-" + Date.now()
    },
    {
      headers: {
        Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
      }
    }
  );

  await db.query(
    "UPDATE orders SET rider_transfer_reference = $1 WHERE id = $2",
    [response.data.data.reference, orderId]
  );
}

The Transfers API moves money from your Paystack balance to the rider's bank account or mobile money wallet. The source: "balance" parameter means it comes from your settled funds.

You also need to handle the transfer.success and transfer.failed webhook events. If a rider transfer fails (wrong account number, network issue), you need to retry or flag it for manual review. Do not assume every transfer succeeds.

For busy periods, consider batching rider payouts. Instead of paying each rider after each delivery, accumulate the day's deliveries and pay out once at end of day using the Bulk Transfers endpoint. This reduces the number of API calls and transfer fees.

Cancellations and Refunds

Cancellations create complexity because money might already be split between your account and the restaurant subaccount. Handle cancellations based on when they happen:

Before restaurant confirms: Full refund. The restaurant has not started any work. Call the Paystack Refund endpoint with the original transaction reference. Since the restaurant's split has not settled yet, the full amount returns to the customer.

After restaurant starts preparing: This is a business decision. You might refund the delivery fee but not the food cost, since the restaurant has already started cooking. Or you might absorb the cost to keep customers happy. Implement partial refunds using the Paystack Refund API with a specific amount.

After pickup: Generally no refund unless the food is wrong or damaged. Handle this through your customer support flow.

async function cancelOrder(orderId, reason) {
  var order = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);

  if (order.status === "delivered") {
    throw new Error("Cannot cancel a delivered order");
  }

  var refundAmount = order.total_amount;
  if (order.status === "preparing" || order.status === "ready") {
    refundAmount = order.delivery_fee; // Only refund delivery fee
  }

  if (refundAmount > 0) {
    await axios.post(
      "https://api.paystack.co/refund",
      {
        transaction: order.paystack_reference,
        amount: refundAmount
      },
      {
        headers: {
          Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
        }
      }
    );
  }

  await db.query(
    "UPDATE orders SET status = $1, cancel_reason = $2, updated_at = NOW() WHERE id = $3",
    ["cancelled", reason, orderId]
  );
}

Track every cancellation with a reason. This data helps you identify problem restaurants (frequent cancellations due to unavailable items) and problem customers (serial cancellers).

Deployment and Testing

Deploy the backend on Railway, Render, or a VPS with a publicly accessible webhook URL. The webhook endpoint must be reachable over HTTPS for Paystack to deliver events.

Test the full flow in Paystack test mode:

  1. Create a test restaurant with a subaccount
  2. Place an order and pay using Paystack test card numbers
  3. Verify the webhook fires and the order status advances
  4. Walk the order through each status to delivered
  5. Trigger a rider payout and verify the transfer.success webhook
  6. Test a cancellation and verify the refund processes

Set up error alerting on three critical points: webhook processing failures, transfer failures, and orders stuck in a status for more than 30 minutes. A "confirmed" order that never moves to "preparing" means the restaurant notification failed or they are ignoring it.

For production, add rate limiting on the order creation endpoint, input validation on all user-submitted data, and a circuit breaker on the Paystack API calls. If Paystack is temporarily down, queue the transaction initialization and retry rather than showing the customer an error.

Key Takeaways

  • Use Paystack subaccounts to send the restaurant its share of each order automatically at settlement time. You never hold the restaurant funds in your own account.
  • Pay delivery riders separately using the Transfers API after delivery confirmation. Do not include riders in the split payment because their share depends on distance and delivery status.
  • Store menu item prices in the smallest currency unit from the start. A bowl of jollof at 1500 NGN is stored as 150000 kobo in your database.
  • Track order status through a clear state machine: placed, confirmed, preparing, ready, picked_up, delivered, cancelled. Each transition should be logged with a timestamp.
  • Use Paystack metadata to attach the order ID and item breakdown to every transaction. This makes reconciliation and dispute resolution straightforward.
  • Make your webhook handler idempotent. If Paystack delivers the charge.success event twice, your system should not create two orders or trigger two rider dispatches.

Frequently Asked Questions

How do I handle tips for the delivery rider?
Add an optional tip field to the order. Include the tip in the total amount charged to the customer, but do not include it in the split payment to the restaurant. When you pay the rider via the Transfers API, add the tip to the delivery fee. The transfer amount becomes delivery_fee plus tip_amount.
Can I use Paystack to handle multiple restaurants with different commission rates?
Yes. Each restaurant has its own subaccount with its own percentage_charge. Restaurant A might get 85% while Restaurant B gets 80%, based on your agreements. When you initialize the transaction, you reference the specific restaurant subaccount, and Paystack applies that subaccount percentage automatically.
What happens if the rider never delivers the food?
Build a timeout mechanism. If an order stays in "picked_up" status for longer than a reasonable delivery window (say 90 minutes), flag it for manual review. Do not auto-pay the rider until delivery is confirmed. You can use GPS tracking or a simple "Mark as delivered" button that the customer confirms.
How do I handle orders where the customer pays with M-Pesa in Kenya?
No special code is needed. When you initialize the transaction with currency set to KES, Paystack automatically offers M-Pesa as a payment option on their checkout page. The webhook fires the same way regardless of payment method. The only difference is settlement timing, which Paystack handles on their end.
Can I add a minimum order amount?
Yes. Validate the cart total on your backend before initializing the Paystack transaction. If the total is below your minimum (say 50000 kobo for 500 NGN), return an error asking the customer to add more items. Do not rely on frontend validation alone since someone could call your API directly.

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