Ad Code

How to: Build a WhatsApp E-commerce Flow - Part 5

  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-proxy is 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-callback and use the raw body option (Netlify supports raw body for functions).
    • Use Netlify Scheduled Functions or an external cron to run cleanup-reservations.js or run it as a separate serverless function invoked by a scheduler.
  • 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:

Post a Comment

0 Comments