Ad Code

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

 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-callback endpoints.
  • 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 token
  • WHATSAPP_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_LEVELinfo/debug etc.

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 binaryMediaTypes or use @netlify/functions raw 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 pm2 if 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_confirmation and shipping_update templates 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_TOKEN and STRIPE_SECRET regularly; 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:


Post a Comment

0 Comments