How to: Build a WhatsApp E-commerce Flow - Node.js webhook handler — ready to run (Express) with Netlify and cPanel deployment notes
Below is a complete, copy‑paste ready Node.js/Express app that implements the WhatsApp webhook receiver, idempotency, reservation TTL, provisional order creation, Stripe checkout webhook verification, and sending WhatsApp messages via the Cloud API. It uses Redis for idempotency and reservations and Postgres for persistent data. I also include a small SQL migration, environment variables, and step‑by‑step deployment notes for Netlify Functions (serverless) and cPanel (traditional hosting).
What this package includes
- Express app with
/webhook,/create-order,/checkout-session,/payment-callbackendpoints. - Idempotency using Redis (prevents double processing of webhooks).
- Reservations stored in Redis with TTL to avoid oversell.
- Stripe webhook verification example for hosted checkout.
- WhatsApp Cloud API send helper (template and interactive messages).
- SQL migration for minimal tables:
products,variants,orders. - Deployment notes for Netlify Functions and cPanel.
1. Environment variables (set these before running)
Required
PORT— port for Express (e.g.,3000)DATABASE_URL— Postgres connection string (e.g.,postgres://user:pass@host:5432/db)REDIS_URL— Redis connection string (e.g.,redis://:password@host:6379)WHATSAPP_PHONE_NUMBER_ID— WhatsApp Phone Number ID (for Cloud API)WHATSAPP_ACCESS_TOKEN— WhatsApp Cloud API access tokenWHATSAPP_APP_SECRET— App secret for verifying X‑Hub signature (optional but recommended)VERIFY_TOKEN— webhook verify token (for GET verification)STRIPE_SECRET— Stripe secret key (server)STRIPE_WEBHOOK_SECRET— Stripe webhook signing secret (for/payment-callback)BASE_URL— public URL of your backend (used for webhook setup and checkout success/cancel URLs)
Optional
LOG_LEVEL—info/debugetc.
2. SQL migration (minimal)
Run this on your Postgres DB to create the minimal schema used by the app.
-- migration.sql
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
price_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'MYR',
images JSONB,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
CREATE TABLE variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES products(id) ON DELETE CASCADE,
variant_sku TEXT,
title TEXT,
price_delta_cents INTEGER DEFAULT 0,
stock INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id TEXT UNIQUE,
customer_phone TEXT,
items JSONB,
subtotal_cents INTEGER,
shipping_cents INTEGER DEFAULT 0,
tax_cents INTEGER DEFAULT 0,
total_cents INTEGER,
payment_status TEXT DEFAULT 'pending',
fulfillment_status TEXT DEFAULT 'pending',
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
3. Full Node.js app (Express) — index.js
Save this as index.js. Install dependencies listed after the code.
// index.js
'use strict';
const express = require('express');
const fetch = require('node-fetch');
const crypto = require('crypto');
const { Pool } = require('pg');
const Redis = require('ioredis');
const bodyParser = require('body-parser');
const Stripe = require('stripe');
const app = express();
// Raw body capture for signature verification
app.use(bodyParser.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
// Environment
const {
PORT = 3000,
DATABASE_URL,
REDIS_URL,
WHATSAPP_PHONE_NUMBER_ID,
WHATSAPP_ACCESS_TOKEN,
WHATSAPP_APP_SECRET,
VERIFY_TOKEN,
STRIPE_SECRET,
STRIPE_WEBHOOK_SECRET,
BASE_URL
} = process.env;
if (!DATABASE_URL || !REDIS_URL || !WHATSAPP_PHONE_NUMBER_ID || !WHATSAPP_ACCESS_TOKEN || !VERIFY_TOKEN || !STRIPE_SECRET || !STRIPE_WEBHOOK_SECRET || !BASE_URL) {
console.error('Missing required environment variables. See README for required env vars.');
process.exit(1);
}
const stripe = Stripe(STRIPE_SECRET);
const pg = new Pool({ connectionString: DATABASE_URL });
const redis = new Redis(REDIS_URL);
// Utility: idempotency using Redis
async function isDuplicateEvent(key) {
const exists = await redis.get(`idempotency:${key}`);
return !!exists;
}
async function markEventProcessed(key, ttlSeconds = 60 * 60 * 24) {
await redis.set(`idempotency:${key}`, '1', 'EX', ttlSeconds);
}
// Utility: reservations (reserve stock for order)
async function reserveSku(sku, qty, orderId, ttlSeconds = 10 * 60) {
const key = `reservation:${sku}:${orderId}`;
const exists = await redis.get(key);
if (exists) return false;
await redis.set(key, qty.toString(), 'EX', ttlSeconds);
// track reservation index for cleanup
await redis.sadd(`reservations_by_sku:${sku}`, key);
return true;
}
async function releaseReservationsForOrder(orderId) {
const pattern = `reservation:*:${orderId}`;
const keys = await redis.keys(pattern);
if (!keys.length) return;
for (const k of keys) {
// remove from sku set if possible
const parts = k.split(':'); // reservation:sku:orderId
if (parts.length === 3) {
const sku = parts[1];
await redis.srem(`reservations_by_sku:${sku}`, k);
}
await redis.del(k);
}
}
// WhatsApp send helper
async function sendWhatsAppMessage(toPhone, payload) {
const url = `https://graph.facebook.com/v18.0/${WHATSAPP_PHONE_NUMBER_ID}/messages`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${WHATSAPP_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(Object.assign({ messaging_product: 'whatsapp', to: toPhone }, payload))
});
const json = await res.json();
if (!res.ok) {
console.error('WhatsApp send error', json);
throw new Error('WhatsApp send failed');
}
return json;
}
// Verify WhatsApp signature (X-Hub-Signature-256)
function verifyWhatsAppSignature(req) {
if (!WHATSAPP_APP_SECRET) return true; // skip if not configured
const sig = req.headers['x-hub-signature-256'] || req.headers['x-hub-signature'];
if (!sig) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(req.rawBody).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch (e) {
return false;
}
}
// GET webhook verification (WhatsApp)
app.get('/webhook', (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 === VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
// POST webhook receiver
app.post('/webhook', async (req, res) => {
try {
if (!verifyWhatsAppSignature(req)) {
console.warn('Invalid WhatsApp signature');
return res.status(401).send('Invalid signature');
}
const body = req.body;
if (!body || !body.entry) return res.sendStatus(200);
for (const entry of body.entry) {
for (const change of entry.changes || []) {
const value = change.value || {};
// messages
if (value.messages) {
for (const msg of value.messages) {
const eventId = msg.id || `${entry.id}:${msg.id}`;
if (await isDuplicateEvent(eventId)) continue;
await markEventProcessed(eventId);
// handle text
if (msg.type === 'text') {
const text = msg.text?.body || '';
const from = msg.from;
// Simple intent parsing: "buy SKU123" or "show SKU123"
const match = text.match(/(buy|show|sku)\s*[:#]?\s*([A-Za-z0-9-_]+)/i);
if (match) {
const sku = match[2];
// send product detail or create provisional order
await handleProductSelection(from, sku, msg.id);
} else {
// fallback: send product list interactive (example)
await sendWhatsAppMessage(from, {
type: 'interactive',
interactive: {
type: 'list',
header: { type: 'text', text: 'Catalog' },
body: { text: 'Choose a category' },
footer: { text: 'Tap to browse' },
action: {
button: 'Browse',
sections: [
{ title: 'Featured', rows: [{ id: 'sku:SKU123', title: 'SKU123 - Example', description: 'Tap to view' }] }
]
}
}
});
}
}
// interactive replies (product selection)
if (msg.type === 'interactive') {
const interactive = msg.interactive;
const from = msg.from;
if (interactive.type === 'product_list_selection' || interactive.type === 'product') {
const retailerId = interactive.product_retailer_id || interactive.list_reply?.id || interactive.button_reply?.id;
// normalize if prefixed
const sku = (retailerId || '').replace(/^sku:/i, '');
if (sku) {
await handleProductSelection(from, sku, msg.id);
}
} else if (interactive.type === 'button_reply' || interactive.type === 'list_reply') {
const id = interactive.button_reply?.id || interactive.list_reply?.id;
if (id && id.startsWith('sku:')) {
const sku = id.split(':')[1];
await handleProductSelection(from, sku, msg.id);
}
}
}
}
}
// handle statuses, payments, etc. (extend as needed)
}
}
return res.sendStatus(200);
} catch (err) {
console.error('Webhook processing error', err);
return res.sendStatus(500);
}
});
// Helper: handle product selection -> create provisional order and send checkout link
async function handleProductSelection(fromPhone, sku, waMessageId) {
// Lookup product and variant
const client = await pg.connect();
try {
const prodRes = await client.query('SELECT p.id, p.sku, p.title, p.price_cents, p.currency, v.id as variant_id, v.stock, v.price_delta_cents FROM products p LEFT JOIN variants v ON v.product_id = p.id WHERE p.sku = $1 LIMIT 1', [sku]);
if (!prodRes.rows.length) {
// send not found message
await sendWhatsAppMessage(fromPhone, { type: 'text', text: { body: `Sorry, product ${sku} not found.` } });
return;
}
const row = prodRes.rows[0];
const unitPrice = row.price_cents + (row.price_delta_cents || 0);
// create provisional order
const items = [{ sku: row.sku, variant_id: row.variant_id, qty: 1, unit_price_cents: unitPrice }];
const subtotal = unitPrice;
const total = subtotal; // add shipping/tax as needed
const orderRes = await client.query(
`INSERT INTO orders (external_id, customer_phone, items, subtotal_cents, total_cents, payment_status, metadata)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
[`wa_${Date.now()}_${Math.random().toString(36).slice(2,8)}`, fromPhone, JSON.stringify(items), subtotal, total, 'pending', JSON.stringify({ wa_message_id: waMessageId })]
);
const orderId = orderRes.rows[0].id;
// reserve stock in Redis (TTL 10 minutes)
const reserved = await reserveSku(row.sku, 1, orderId, 10 * 60);
if (!reserved) {
await sendWhatsAppMessage(fromPhone, { type: 'text', text: { body: `Sorry, ${row.title} is temporarily unavailable.` } });
return;
}
// create Stripe Checkout session (server-side)
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
line_items: [{
price_data: {
currency: row.currency.toLowerCase(),
product_data: { name: row.title },
unit_amount: unitPrice
},
quantity: 1
}],
metadata: { order_id: orderId },
success_url: `${BASE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/checkout/cancel`
});
// send order summary + checkout link
await sendWhatsAppMessage(fromPhone, {
type: 'text',
text: {
body: `Order created for ${row.title}\nTotal: ${(total/100).toFixed(2)} ${row.currency}\nPay here: ${session.url}`
}
});
return;
} finally {
client.release();
}
}
// Endpoint: create-order (explicit API for your frontend)
app.post('/create-order', async (req, res) => {
const { customer_phone, items, source = 'whatsapp', metadata = {} } = req.body;
if (!customer_phone || !items || !items.length) return res.status(400).json({ error: 'invalid_payload' });
const client = await pg.connect();
try {
// compute totals (simple)
let subtotal = 0;
for (const it of items) {
// lookup product price
const p = await client.query('SELECT price_cents FROM products WHERE sku = $1 LIMIT 1', [it.sku]);
if (!p.rows.length) return res.status(400).json({ error: `sku_not_found:${it.sku}` });
subtotal += (p.rows[0].price_cents + (it.price_delta_cents || 0)) * (it.qty || 1);
}
const total = subtotal;
const externalId = `api_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
const orderRes = await client.query(
`INSERT INTO orders (external_id, customer_phone, items, subtotal_cents, total_cents, payment_status, metadata)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
[externalId, customer_phone, JSON.stringify(items), subtotal, total, 'pending', JSON.stringify(metadata)]
);
const orderId = orderRes.rows[0].id;
// reserve each sku
for (const it of items) {
await reserveSku(it.sku, it.qty || 1, orderId, 10 * 60);
}
// create Stripe session
const line_items = items.map(it => ({
price_data: {
currency: 'myr',
product_data: { name: it.title || it.sku },
unit_amount: it.unit_price_cents || 0
},
quantity: it.qty || 1
}));
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
line_items,
metadata: { order_id: orderId },
success_url: `${BASE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/checkout/cancel`
});
return res.json({ order_id: orderId, checkout_url: session.url });
} catch (err) {
console.error('create-order error', err);
return res.status(500).json({ error: 'server_error' });
} finally {
client.release();
}
});
// Endpoint: Stripe webhook for payment callbacks
app.post('/payment-callback', bodyParser.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error('Stripe webhook signature verification failed', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
const eventId = event.id;
if (await isDuplicateEvent(`stripe:${eventId}`)) {
return res.status(200).send('duplicate');
}
await markEventProcessed(`stripe:${eventId}`);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const orderId = session.metadata?.order_id;
if (!orderId) {
console.warn('No order_id in session metadata');
return res.status(200).send('no_order');
}
// mark order paid and release reservations
const client = await pg.connect();
try {
await client.query('UPDATE orders SET payment_status=$1, updated_at=now() WHERE id=$2', ['paid', orderId]);
await releaseReservationsForOrder(orderId);
// fetch order to get phone and send confirmation
const o = await client.query('SELECT customer_phone, total_cents FROM orders WHERE id=$1 LIMIT 1', [orderId]);
if (o.rows.length) {
const phone = o.rows[0].customer_phone;
const total = o.rows[0].total_cents;
await sendWhatsAppMessage(phone, {
type: 'template',
template: {
name: 'order_confirmation', // ensure template approved
language: { code: 'en_US' },
components: [
{ type: 'body', parameters: [{ type: 'text', text: orderId }, { type: 'text', text: (total/100).toFixed(2) }] }
]
}
});
}
} catch (err) {
console.error('Error marking order paid', err);
} finally {
client.release();
}
}
return res.status(200).send('ok');
});
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Start server
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
4. Dependencies and install
Run:
npm init -y
npm install express node-fetch pg ioredis body-parser stripe
# If using Node 18+, node-fetch may be unnecessary; adjust accordingly.
Add a start script in package.json:
"scripts": {
"start": "node index.js"
}
5. Netlify Functions deployment (serverless) — notes
If you prefer Netlify serverless functions instead of a long‑running Express server:
- Convert each endpoint into a Netlify function (one file per endpoint) under
netlify/functions/. - Use the same logic but export
handler = async (event, context) => { ... }. - For Stripe webhook verification, Netlify requires raw body; configure
binaryMediaTypesor use@netlify/functionsraw body handling. - Environment variables are set in Netlify UI (
Site settings → Build & deploy → Environment). - For local testing, use
netlify dev.
Tradeoffs
- Netlify functions are great for scaling and zero server maintenance.
- Long‑running tasks (reservation cleanup cron) need a scheduled function or external cron (Netlify Scheduled Functions or external scheduler).
- For high throughput, ensure Redis and Postgres are accessible from Netlify (VPC or public with IP allowlist).
6. cPanel deployment (traditional) — notes
To host on cPanel (shared hosting):
- Ensure Node.js is supported on your cPanel plan (some shared hosts provide Node app manager).
- Upload project files via SFTP or Git.
- Configure Node app in cPanel: set
Document Root,App Directory, and environment variables. - Use a process manager (cPanel handles process lifecycle) or configure
pm2if allowed. - Ensure Redis and Postgres are reachable from the cPanel server (use managed services or host them on accessible endpoints).
If cPanel cannot run Node:
- Use a small VPS (DigitalOcean, Linode) or container host for the Express app and keep cPanel for static site or admin UI.
7. Operational recommendations and next steps
- Template approval: pre-submit
order_confirmationandshipping_updatetemplates to WhatsApp early. Use placeholders for order id and totals. - Reservation cleanup: implement a scheduled job (cron or serverless scheduled function) to scan expired reservations and release them. The code above uses Redis TTL and a set index; add a periodic job to reconcile.
- Idempotency: store processed event IDs for at least 24 hours.
- Monitoring: add logging (structured), Sentry or similar for errors, and metrics for message delivery and payment success.
- Security: rotate
WHATSAPP_ACCESS_TOKENandSTRIPE_SECRETregularly; restrict access to Redis/Postgres by IP where possible. - Testing: run end‑to‑end tests with a test WhatsApp number and Stripe test keys. Use the Postman collection you already have to simulate flows.
Follow next updates below:
- Produce the Netlify function versions of
/webhookand/payment-callback(ready to drop intonetlify/functions/), or - Generate a runnable Dockerfile for the Express app so you can deploy to any host (VPS, container service), or
- Create the reservation cleanup cron script and a small Redis Lua script for atomic reservation checks.
0 Comments