How to: Build a WhatsApp Commerce Flow with Netlify, cPanel, and Docker - Part 5
Netlify Functions Ready to Drop In
Files netlify/functions/webhook.js and netlify/functions/payment-callback.js. Set environment variables in Netlify UI: DATABASE_URL, REDIS_URL, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_ACCESS_TOKEN, WHATSAPP_APP_SECRET, VERIFY_TOKEN, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, BASE_URL.
webhook.js
// netlify/functions/webhook.js
const { Pool } = require('pg');
const Redis = require('ioredis');
const fetch = require('node-fetch');
const crypto = require('crypto');
const {
DATABASE_URL,
REDIS_URL,
WHATSAPP_PHONE_NUMBER_ID,
WHATSAPP_ACCESS_TOKEN,
WHATSAPP_APP_SECRET,
VERIFY_TOKEN,
BASE_URL
} = process.env;
const pg = new Pool({ connectionString: DATABASE_URL });
const redis = new Redis(REDIS_URL);
// helper: verify X-Hub signature
function verifyWhatsAppSignature(rawBody, headers) {
if (!WHATSAPP_APP_SECRET) return true;
const sig = headers['x-hub-signature-256'] || headers['x-hub-signature'];
if (!sig) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(rawBody).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch (e) {
return false;
}
}
async function isDuplicateEvent(key) {
return !!(await redis.get(`idempotency:${key}`));
}
async function markEventProcessed(key, ttl = 24 * 3600) {
await redis.set(`idempotency:${key}`, '1', 'EX', ttl);
}
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))
});
return res.json();
}
exports.handler = async function (event) {
// GET verification
if (event.httpMethod === 'GET') {
const params = event.queryStringParameters || {};
if (params['hub.mode'] === 'subscribe' && params['hub.verify_token'] === VERIFY_TOKEN) {
return { statusCode: 200, body: params['hub.challenge'] || '' };
}
return { statusCode: 403, body: 'forbidden' };
}
// POST webhook
const rawBody = event.body || '';
const headers = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v]));
if (!verifyWhatsAppSignature(rawBody, headers)) {
return { statusCode: 401, body: 'invalid signature' };
}
let body;
try { body = JSON.parse(rawBody); } catch (e) { return { statusCode: 400, body: 'invalid json' }; }
try {
for (const entry of body.entry || []) {
for (const change of entry.changes || []) {
const value = change.value || {};
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);
// text intent
if (msg.type === 'text') {
const text = msg.text?.body || '';
const from = msg.from;
const match = text.match(/(buy|show|sku)\s*[:#]?\s*([A-Za-z0-9-_]+)/i);
if (match) {
const sku = match[2];
// create provisional order flow via internal API
await fetch(`${BASE_URL}/.netlify/functions/create-order-proxy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: from, sku, wa_message_id: msg.id })
});
} else {
// send a simple list fallback
await sendWhatsAppMessage(from, {
type: 'interactive',
interactive: {
type: 'list',
header: { type: 'text', text: 'Catalog' },
body: { text: 'Choose a product' },
action: {
button: 'Browse',
sections: [{ title: 'Featured', rows: [{ id: 'sku:SKU123', title: 'SKU123', description: 'Tap to view' }] }]
}
}
});
}
}
// interactive product selection
if (msg.type === 'interactive') {
const interactive = msg.interactive;
const from = msg.from;
const retailerId = interactive.product_retailer_id || interactive.list_reply?.id || interactive.button_reply?.id;
if (retailerId) {
const sku = retailerId.replace(/^sku:/i, '');
await fetch(`${BASE_URL}/.netlify/functions/create-order-proxy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: from, sku, wa_message_id: msg.id })
});
}
}
}
}
}
}
return { statusCode: 200, body: 'ok' };
} catch (err) {
console.error('webhook error', err);
return { statusCode: 500, body: 'server error' };
}
};
Note:
create-order-proxyis a small Netlify function that calls your main backend or runs the order creation logic. Keep heavy DB work in a dedicated function or external service to avoid function timeouts.
payment-callback.js
// netlify/functions/payment-callback.js
const Stripe = require('stripe');
const Redis = require('ioredis');
const { Pool } = require('pg');
const {
STRIPE_SECRET,
STRIPE_WEBHOOK_SECRET,
REDIS_URL,
DATABASE_URL,
WHATSAPP_PHONE_NUMBER_ID,
WHATSAPP_ACCESS_TOKEN
} = process.env;
const stripe = Stripe(STRIPE_SECRET);
const redis = new Redis(REDIS_URL);
const pg = new Pool({ connectionString: DATABASE_URL });
async function isDuplicateEvent(key) {
return !!(await redis.get(`idempotency:${key}`));
}
async function markEventProcessed(key, ttl = 24 * 3600) {
await redis.set(`idempotency:${key}`, '1', 'EX', ttl);
}
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))
});
return res.json();
}
exports.handler = async function (event) {
// Stripe requires raw body for signature verification
const sig = event.headers['stripe-signature'] || event.headers['Stripe-Signature'];
const rawBody = event.body;
let stripeEvent;
try {
stripeEvent = stripe.webhooks.constructEvent(rawBody, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error('stripe webhook verify failed', err.message);
return { statusCode: 400, body: `Webhook Error: ${err.message}` };
}
const eventId = stripeEvent.id;
if (await isDuplicateEvent(`stripe:${eventId}`)) return { statusCode: 200, body: 'duplicate' };
await markEventProcessed(`stripe:${eventId}`);
try {
if (stripeEvent.type === 'checkout.session.completed') {
const session = stripeEvent.data.object;
const orderId = session.metadata?.order_id;
if (!orderId) return { statusCode: 200, body: 'no order' };
// mark order paid
const client = await pg.connect();
try {
await client.query('UPDATE orders SET payment_status=$1, updated_at=now() WHERE id=$2', ['paid', orderId]);
// fetch phone and total
const r = await client.query('SELECT customer_phone, total_cents FROM orders WHERE id=$1 LIMIT 1', [orderId]);
if (r.rows.length) {
const phone = r.rows[0].customer_phone;
const total = r.rows[0].total_cents;
await sendWhatsAppMessage(phone, {
type: 'template',
template: {
name: 'order_confirmation',
language: { code: 'en_US' },
components: [{ type: 'body', parameters: [{ type: 'text', text: orderId }, { type: 'text', text: (total/100).toFixed(2) }] }]
}
});
}
} finally {
client.release();
}
}
return { statusCode: 200, body: 'ok' };
} catch (err) {
console.error('payment-callback error', err);
return { statusCode: 500, body: 'server error' };
}
};
Dockerfile for the Express App and docker-compose
Dockerfile for the full Express app (use the index.js server from earlier).
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "index.js"]
docker-compose.yml with Postgres and Redis for local testing
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- PORT=3000
- DATABASE_URL=postgres://postgres:postgres@db:5432/wa
- REDIS_URL=redis://redis:6379
- WHATSAPP_PHONE_NUMBER_ID=${WHATSAPP_PHONE_NUMBER_ID}
- WHATSAPP_ACCESS_TOKEN=${WHATSAPP_ACCESS_TOKEN}
- WHATSAPP_APP_SECRET=${WHATSAPP_APP_SECRET}
- VERIFY_TOKEN=${VERIFY_TOKEN}
- STRIPE_SECRET=${STRIPE_SECRET}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- BASE_URL=${BASE_URL}
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: wa
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
command: ["redis-server", "--save", "60", "1"]
volumes:
db-data:
Reservation Cleanup Cron Script and Redis Lua Atomic Reservation
Purpose run periodically to reconcile expired reservations and release stock. Use a scheduled job (cron, Netlify Scheduled Functions, or a container cron).
cleanup-reservations.js
// cleanup-reservations.js
const Redis = require('ioredis');
const { Pool } = require('pg');
const { REDIS_URL, DATABASE_URL } = process.env;
const redis = new Redis(REDIS_URL);
const pg = new Pool({ connectionString: DATABASE_URL });
async function cleanup() {
// pattern: reservations_by_sku:{sku} contains reservation keys
const keys = await redis.keys('reservations_by_sku:*');
for (const setKey of keys) {
const sku = setKey.split(':')[1];
const members = await redis.smembers(setKey);
for (const reservationKey of members) {
const exists = await redis.exists(reservationKey);
if (!exists) {
// reservation expired, remove from set
await redis.srem(setKey, reservationKey);
// optionally reconcile DB stock if you decrement stock only on finalization
// reservationKey format: reservation:{sku}:{orderId}
const parts = reservationKey.split(':');
const orderId = parts[2];
// mark order as expired if still pending
try {
await pg.query('UPDATE orders SET fulfillment_status=$1 WHERE id=$2 AND payment_status=$3', ['expired', orderId, 'pending']);
} catch (e) {
console.error('db update error', e);
}
}
}
}
console.log('cleanup done');
process.exit(0);
}
cleanup().catch(err => { console.error(err); process.exit(1); });
Redis Lua script for atomic reserve check and decrement
-- reserve.lua
-- ARGV[1] = sku
-- ARGV[2] = qty
-- ARGV[3] = orderId
-- ARGV[4] = ttlSeconds
local sku = ARGV[1]
local qty = tonumber(ARGV[2])
local orderId = ARGV[3]
local ttl = tonumber(ARGV[4])
local stockKey = "stock:" .. sku
local stock = tonumber(redis.call("GET", stockKey) or "0")
if stock < qty then
return { err = "insufficient_stock" }
end
-- decrement stock atomically
redis.call("DECRBY", stockKey, qty)
local reservationKey = "reservation:" .. sku .. ":" .. orderId
redis.call("SET", reservationKey, qty, "EX", ttl)
redis.call("SADD", "reservations_by_sku:" .. sku, reservationKey)
return { ok = "reserved" }
Usage: load the script into Redis and call EVAL or EVALSHA from your app to reserve stock atomically.
Deployment Notes for Netlify and cPanel
Netlify
- Place functions under
netlify/functions/. - Set environment variables in Site settings.
- For Stripe webhooks, configure the webhook endpoint to
https://<site>/.netlify/functions/payment-callbackand use the raw body option (Netlify supports raw body for functions). - Use Netlify Scheduled Functions or an external cron to run
cleanup-reservations.jsor run it as a separate serverless function invoked by a scheduler.
- Place functions under
cPanel
- If cPanel supports Node apps, upload the project, set environment variables in the Node App manager, and start the app.
- Ensure Postgres and Redis are reachable; prefer managed DB/Redis with private networking.
- Use a process manager (pm2) if allowed, or rely on cPanel's Node manager.
Docker
- Use the provided Dockerfile and docker-compose for local testing and for deploying to VPS or container platforms.
Next, I will perform below on next post:
- Produce a GitHub repo README with copy‑paste commands and
.env.example, or - Generate the Netlify function
create-order-proxyand a smallREADME.mdfor the blog post.
0 Comments