A WhatsApp bot that collects payment through Paystack works by receiving customer messages via the WhatsApp Business API, guiding them through product selection and order confirmation, then generating a Paystack payment link and sending it in the chat. When the customer pays, a Paystack webhook triggers a confirmation message back to the customer on WhatsApp.
A WhatsApp payment bot has three moving parts that talk to each other through webhooks and API calls:
The data flow for a complete purchase looks like this:
Two separate webhooks are at play: one from WhatsApp (incoming messages) and one from Paystack (payment events). Your server needs to handle both on different endpoints.
You cannot send automated WhatsApp messages from a personal account. You need access to the WhatsApp Business API. There are three main ways to get it:
Meta Cloud API (direct). This is Meta's own hosted WhatsApp API. You set it up through the Meta for Developers portal. Free for the first 1,000 conversations per month. No third-party middleman. The documentation is decent but can be confusing for first-timers. This is the option most developers in Kenya should start with because it has the lowest cost.
Africa's Talking. A Nairobi-based communications platform that offers WhatsApp Business API access alongside SMS, USSD, and voice. If you already use Africa's Talking for SMS or USSD, adding WhatsApp is straightforward. Their API wraps Meta's WhatsApp API with a more familiar interface for African developers.
Twilio. A global communications platform with WhatsApp API access. Mature SDK, good documentation, and predictable pricing. More expensive than the direct Meta option, but some teams prefer Twilio's developer experience.
For this guide, the code examples use the Meta Cloud API directly. The patterns are the same regardless of provider. You receive a message webhook, process it, and send a reply.
Whichever provider you choose, you need:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When a customer sends a message to your WhatsApp Business number, Meta delivers it to your webhook as an HTTP POST request. You need to set up two things: a GET endpoint for webhook verification and a POST endpoint for receiving messages.
const express = require('express');
const app = express();
app.use(express.json());
// WhatsApp webhook verification (GET)
app.get('/webhooks/whatsapp', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === process.env.WA_VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
res.sendStatus(403);
});
// WhatsApp incoming messages (POST)
app.post('/webhooks/whatsapp', async (req, res) => {
// Always return 200 immediately
res.status(200).send('OK');
const body = req.body;
if (!body.entry?.[0]?.changes?.[0]?.value?.messages?.[0]) {
return; // Not a message event (could be a status update)
}
const message = body.entry[0].changes[0].value.messages[0];
const customerPhone = message.from; // Format: 254712345678
const messageText = message.text?.body?.trim().toLowerCase() || '';
// Route to conversation handler
await handleConversation(customerPhone, messageText);
});
The GET endpoint is called once when you register the webhook with Meta. The POST endpoint receives every incoming message. Return 200 immediately on the POST, then process the message asynchronously. If you take too long to respond, Meta may retry the delivery, causing duplicate processing.
A payment bot needs a conversation flow that feels natural, not like filling out a form. The simplest effective flow has four states:
// Conversation states
var STATES = {
IDLE: 'idle', // Waiting for the customer to start
BROWSING: 'browsing', // Showing products
CONFIRMING: 'confirming', // Confirming order details
AWAITING_PAYMENT: 'awaiting_payment', // Payment link sent
};
// Simple in-memory state (use Redis or a database in production)
var conversations = new Map();
async function handleConversation(phone, text) {
var state = conversations.get(phone) || { step: STATES.IDLE, cart: [] };
switch (state.step) {
case STATES.IDLE:
await sendProductMenu(phone);
state.step = STATES.BROWSING;
break;
case STATES.BROWSING:
var product = findProduct(text);
if (product) {
state.cart.push(product);
await sendMessage(phone,
'Added ' + product.name + ' (KES ' + product.price + '). ' +
'Reply "done" to checkout or pick another item.'
);
} else if (text === 'done' && state.cart.length > 0) {
var total = state.cart.reduce(function(s, p) { return s + p.price; }, 0);
var summary = state.cart.map(function(p) { return p.name; }).join(', ');
await sendMessage(phone,
'Your order: ' + summary + '
Total: KES ' + total.toLocaleString() + '
' +
'Reply "confirm" to get your payment link or "cancel" to start over.'
);
state.step = STATES.CONFIRMING;
} else {
await sendMessage(phone, 'I did not recognize that. Reply with a product number or name.');
}
break;
case STATES.CONFIRMING:
if (text === 'confirm') {
var total = state.cart.reduce(function(s, p) { return s + p.price; }, 0);
var paymentLink = await generatePaystackLink(phone, state.cart, total);
await sendMessage(phone,
'Pay here: ' + paymentLink + '
Tap the link to pay with M-Pesa or card. ' +
'I will confirm once your payment goes through.'
);
state.step = STATES.AWAITING_PAYMENT;
} else if (text === 'cancel') {
state = { step: STATES.IDLE, cart: [] };
await sendMessage(phone, 'Order cancelled. Send any message to start again.');
}
break;
case STATES.AWAITING_PAYMENT:
await sendMessage(phone,
'I sent you a payment link. Please complete the payment there. ' +
'If the link expired, reply "new link" and I will generate a fresh one.'
);
if (text === 'new link') {
var total = state.cart.reduce(function(s, p) { return s + p.price; }, 0);
var newLink = await generatePaystackLink(phone, state.cart, total);
await sendMessage(phone, 'Here is a new link: ' + newLink);
}
break;
}
conversations.set(phone, state);
}
This is a minimal state machine. In a production bot, you would add:
When the customer confirms their order, you call the Paystack Transaction Initialize endpoint to create a payment link specific to that order.
async function generatePaystackLink(phone, cart, totalKES) {
var orderId = 'bot-' + phone + '-' + Date.now();
var totalCents = totalKES * 100;
// Save order first
await saveOrder({
id: orderId,
phone: phone,
items: cart,
totalKES: totalKES,
status: 'pending_payment',
createdAt: new Date(),
});
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({
// Use a generated email if customer has not provided one
email: phone + '@wa-orders.yoursite.co.ke',
amount: totalCents,
currency: 'KES',
reference: orderId,
callback_url: 'https://yoursite.co.ke/order-complete',
metadata: {
order_id: orderId,
customer_phone: phone,
channel: 'whatsapp_bot',
items: cart.map(function(p) { return p.name; }),
},
}),
});
var data = await res.json();
if (!data.status) {
throw new Error('Paystack link generation failed: ' + data.message);
}
return data.data.authorization_url;
}
Each order gets a unique reference that includes the phone number and timestamp. This makes it easy to trace any transaction back to the specific WhatsApp conversation.
The email field is required by Paystack. If your customers do not have email addresses (common in informal commerce), you can generate a placeholder email from their phone number. Paystack uses the email for receipts, so if you use a placeholder, the customer will not get an email receipt. That is fine for WhatsApp commerce, because you send the receipt in the chat instead.
When the customer completes payment, Paystack sends a charge.success webhook to your server. Your server then sends a confirmation message back to the customer on WhatsApp.
// Paystack webhook handler
app.post('/webhooks/paystack', express.json(), async (req, res) => {
res.status(200).send('OK');
var hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
console.error('Invalid Paystack webhook signature');
return;
}
var event = req.body;
if (event.event === 'charge.success') {
var reference = event.data.reference;
var amount = event.data.amount;
var metadata = event.data.metadata;
var phone = metadata.customer_phone;
var orderId = metadata.order_id;
// Idempotency check: only process each payment once
var order = await getOrder(orderId);
if (!order || order.status === 'paid') {
return; // Already processed or unknown order
}
// Verify amount matches
if (amount !== order.totalKES * 100) {
console.error('Amount mismatch for ' + orderId +
': expected ' + (order.totalKES * 100) + ', got ' + amount);
return;
}
// Update order status
await updateOrderStatus(orderId, 'paid', { paystackRef: reference });
// Update conversation state
conversations.set(phone, { step: STATES.IDLE, cart: [] });
// Send confirmation on WhatsApp
var items = order.items.map(function(p) { return p.name; }).join(', ');
await sendMessage(phone,
'Payment received! Your order (' + items + ') is confirmed. ' +
'We will send you a delivery update shortly. Thank you!'
);
}
});
The idempotency check is essential. Paystack may retry the webhook if your server was slow to respond. Without the check, the customer gets duplicate "Payment received!" messages. Check the order status before processing. If it is already "paid," skip the message.
Also verify the amount. If the webhook says the customer paid KES 500 but the order was KES 5,000, something is wrong. Do not confirm the order.
After payment confirmation, the customer expects updates. Here is where the 24-hour messaging window becomes important.
Within 24 hours of the customer's last message, you can send freeform text messages. After 24 hours, you need pre-approved message templates. For a payment bot, you should create templates for:
Submit these templates to Meta for approval before you need them. Approval takes anywhere from a few minutes to a couple of days.
// Send a template message (for messages outside the 24-hour window)
async function sendTemplateMessage(phone, templateName, parameters) {
var url = 'https://graph.facebook.com/v18.0/' +
process.env.WHATSAPP_PHONE_ID + '/messages';
await fetch(url, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.WHATSAPP_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to: phone,
type: 'template',
template: {
name: templateName,
language: { code: 'en' },
components: [
{
type: 'body',
parameters: parameters.map(function(p) {
return { type: 'text', text: p };
}),
},
],
},
}),
});
}
// Usage:
// await sendTemplateMessage('254712345678', 'order_dispatched', [
// 'WA-2547-1689900000', // order_id
// '45 minutes', // eta
// ]);
A working demo and a production bot are different things. Here is what separates them.
Conversation state persistence. Use Redis or a database for conversation state. An in-memory Map works for testing but loses all state when the server restarts. A customer who was mid-order will get confused when the bot forgets them.
Message queue. Do not call the WhatsApp API synchronously from inside your webhook handler. Put outgoing messages in a queue (BullMQ, RabbitMQ, or even a simple database queue) and process them in a worker. This prevents webhook timeouts and handles rate limiting from Meta's API.
Error messages. When something goes wrong (Paystack API down, invalid product, network error), the bot should send a helpful message, not go silent. "Something went wrong. Please try again in a few minutes" is better than nothing.
Logging. Log every incoming message, every outgoing message, every Paystack API call, and every webhook. When a customer says "I paid but did not get confirmation," you need to trace the entire flow to find where it broke.
Rate limiting. Meta's WhatsApp API has rate limits. If your bot suddenly gets popular, you may hit them. Implement backoff and retry logic on your outgoing message calls.
Testing. WhatsApp's test environment lets you send messages without using real phone numbers. Use it during development. For Paystack, use test keys and test cards. Do not develop against production APIs.
Security. Your webhook endpoints are public URLs. Verify the Paystack signature on every webhook. For WhatsApp webhooks, verify the payload against the app secret. Do not trust unverified requests.
This guide covers the core pattern: receive a message, manage a conversation, generate a payment link, and confirm payment. From here, you can extend in several directions.
For a simpler approach without a full bot, see WhatsApp Commerce Plus Paystack Payment Links. That covers manual and semi-automated link sharing without the conversation flow.
For reaching customers who do not use WhatsApp (feature phone users), see USSD Plus Paystack: Reaching Feature Phone Users.
For the full Kenya payments context, including M-Pesa specifics, settlement, and regulatory considerations, start with Paystack in Kenya: M-Pesa, Pesalink, and the Daraja Question.
If you want to build production-grade payment systems and ship real products, the McTaba 26-week Full-Stack Software and AI Engineering bootcamp covers payment integration, API design, and deployment for the African market.
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