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.
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:
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.
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: UUIDcustomer_id: Who placed the orderrestaurant_id: Which restaurant fulfills itrider_id: Assigned after the food is ready (nullable until then)subtotal: Sum of item prices in smallest currency unitdelivery_fee: Calculated based on distanceplatform_fee: Your commissiontotal_amount: What the customer paysstatus: placed, confirmed, preparing, ready, picked_up, delivered, cancelledpaystack_reference: Transaction reference for reconciliationrider_transfer_reference: Paystack Transfer reference for rider payoutdelivery_address: JSON with lat, lng, and formatted addressThe 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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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."
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 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).
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:
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course