Skip to content

Latest commit

 

History

197 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PulseCommerce

PulseCommerce

Analytics and WhatsApp campaigns for WooCommerce.

Know who your best customers are, which ones are about to leave, and what they buy — then message them on WhatsApp from the same screen, through a gateway you own.

Next.js 16 React 19 TypeScript Tailwind CSS 4 shadcn/ui


What this is

WooCommerce tells you what sold. PulseCommerce tells you who bought it, which of them is slipping away, what they buy together, which products are about to run out, and what next month looks like — and then lets you act on it.

It is a self-hosted Next.js app. You authorize a store through WooCommerce's own app-authorization endpoint, it pulls your orders over the REST API, caches a snapshot, and derives every metric from that snapshot at request time. WhatsApp sending goes through a gateway on your own infrastructure, so customer numbers never reach a third-party messaging service.

There is no sample data anywhere in this codebase. Every number you see came from your store. A figure can never be a placeholder you mistook for real.


Table of contents


Feature list

Revenue & performance
  • Net revenue, gross revenue, orders, average order value, units sold
  • Discounts given, shipping collected, tax collected, refunded amount
  • Items per order, revenue per customer, cancellation rate
  • Every KPI compared against the equal-length previous window
  • Revenue and orders on one shared axis (no misleading dual-axis charts)
  • Day / week / month bucketing, automatic or manual
  • Sparkline trend on each headline stat
  • Refund rate, cancellation rate and average fulfilment time
  • Generated findings, ranked by urgency, in plain English
Customer analytics
  • RFM scoring — recency, frequency and monetary quintiles of your own base
  • Ten standard RFM segments — Champions, Loyal, Potential Loyalist, New Customers, Promising, Need Attention, At Risk, Cannot Lose Them, Hibernating, Lost
  • Value tiers — VIP, High, Mid, Low, One-time
  • Predicted lifetime value per customer, discounted by churn risk
  • Churn risk score, judged against each customer's own reorder cadence
  • Revenue deciles with a Pareto curve and cumulative share
  • Gini concentration coefficient, top 1/5/10/20% and bottom 50% shares
  • Recency-versus-frequency bubble chart, sized by spend
  • Top customer spotlight with their orders inline
  • Ready-made cohorts: high value, low value, at risk, rising
  • Advanced filters — segment, tier, spend, orders, churn risk, recency, country, product bought, contactability
  • Every customer listed, not a capped slice, rows expand in place
  • Average days between orders, one-time buyer share, repeat rate
Customer profiles
  • Any customer row anywhere drills through to their profile
  • Identity, location, payment method, how long they have been a customer
  • Acquisition channel and device they arrived on
  • RFM scores as filled pips as well as numbers, so never colour-only
  • Revenue percentile, refunds taken, discounts used
  • What they buy, ranked by revenue
  • Order value over time
  • Full order history with status, line items, units, total and net
Acquisition & retention
  • New vs returning revenue by month, stacked
  • Full new- and returning-customer tables, sortable and searchable
  • Acquisition channels from WooCommerce Order Attribution
  • Which channels bring first-time buyers, not just orders
  • Device breakdown by revenue and AOV
  • Pages per session and revenue per customer, per channel
  • Median time to second order, with quartiles and a histogram
  • Cohort retention triangle, unelapsed cells blank rather than zero
  • Cumulative LTV curve per acquired customer
  • Attribution coverage percentage, so partial history is never hidden
Campaigns & audiences
  • Audience builder with live reach, revenue, predicted CLV and churn risk
  • Goal presets — VIP appreciation, win-back lapsed, convert to second order, rescue at-risk high value, loyal advocates, business accounts
  • Filter on segment, tier, recency, spend, orders, churn risk, country, account type, product bought, contactability
  • Live audience preview, exactly matching the export
  • CSV export shaped for email and ads platforms, formula-injection guarded
  • Campaign performance from utm_campaign
  • Coupon performance — uses, discount given, revenue, return on discount
WhatsApp campaigns
  • Send text, image or video through a self-hosted gateway you control
  • Ten templates: announce one product, reorder their product, new in a category they buy, win back a lapsed customer, nudge a one-time buyer, thank a VIP, ask for a review, coupon + their product, coupon only, plain announcement
  • Per-customer variables{{name}}, {{product}}, {{product_url}}, {{category}}, {{last_order}}, {{orders}}, {{spend}}, {{store}}, {{coupon}}, {{coupon_value}}
  • Per-customer product photos — each recipient sees what they buy
  • Product picker — search the catalogue by name, SKU or category and send everyone one specific product instead
  • Coupons — pick an existing WooCommerce coupon, or generate one with an expiry, one use per customer, and a restriction to the campaign product
  • Dry run resolving the real recipient list and sending nothing
  • Test send to a number you type; it cannot reach a customer
  • Typed confirmation of the deliverable count before anything goes out
  • Opt-out list, applied server-side after the audience is built
  • Multi-step flows — up to ten messages, days apart, advanced by a scheduled job; customers join as they qualify and leave the moment they order
  • Flow test mode — point a whole sequence at one number so it can never reach a customer
  • Gateway plugins, including an inbound numbered menu bot that answers when nobody is at a desk
  • Menu builder at /menu — trigger word, greeting and a drag-and-drop option tree, with every move also on a button so it works without a mouse
  • An assistant that reads the store, drafts messages against your real catalogue, and proposes rather than performs
  • Per-page summaries in plain English, on request
  • Paced sending with jitter, as a resumable job with live progress
  • Automatic recovery when the gateway restarts mid-broadcast
  • Link a number by scanning a QR inside Settings
WhatsApp inbox
  • Conversations with the customer behind each number, not raw identifiers
  • Orders and lifetime spend beside every conversation
  • Link straight through to that customer's full profile
  • Thread view that polls for replies while open
  • Composer for text, media, or a product from your catalogue
  • Start a conversation from a typed number, without waiting to be messaged
  • Unread counts, and search across names and numbers
  • Opt-out list applies to one-to-one replies too
Inventory & restock planning
  • Days of cover per SKU from stock and observed velocity
  • Reorder point from a stated lead time plus safety stock
  • Suggested order quantity to reach healthy cover
  • Status per SKU: out of stock, critical, low, healthy, overstocked, untracked
  • Revenue at risk if a critical SKU stays out for one lead time
  • Capital tied up per SKU, to surface overstock
  • Restock planner with reorder / all / overstocked views
  • CSV export shaped as a draft purchase order
Product & catalogue analytics
  • ABC classification — A carries the first 80% of revenue, B the next 15%
  • Pareto concentration chart with the 80% threshold marked
  • Revenue, units, orders, distinct customers, average price per SKU
  • Units-per-day velocity, refund rate per SKU, average rating
  • Market-basket affinity by support, confidence and lift
  • Category revenue and unit mix
  • Best sellers by value and volume; slow movers and never-sold items
Orders & operations
  • Full order register with totals, discount, shipping, tax, refunds, payment method and coupons
  • New vs returning flag per order
  • Order status mix and basket-size distribution
  • Payment method performance by revenue, share and AOV
  • Trading heatmap — day of week × hour of day
  • Weekday revenue performance
  • Fulfilment time, completion share, cancellation and refund rates
Forecasting & geography
  • Daily revenue projection from OLS trend × day-of-week seasonality
  • 95% confidence band from in-sample residuals
  • Implied pace versus the recent actual run rate
  • Revenue, orders, customers and AOV by country, region and city
Search & navigation
  • Command palette over customers, products and orders
  • Filtering runs before rendering, so tens of thousands stay fast
  • Jump to any page, change the date range, re-sync or switch theme from it
  • Keyboard shortcuts throughout, with a ? help sheet
  • Shortcuts ignored while typing, so they never eat a search query
  • Collapsible sidebar; date range and granularity persisted across reloads
Multiple stores
  • Connect several stores and switch between them
  • Switcher in the sidebar; full management in Settings
  • Each keeps its own credentials, data window and snapshot cache
  • Re-authorizing updates a key in place rather than duplicating the store
Reports & exports
  • Ten report types — executive summary, customer ledger, segmentation, products, categories, order register, cohorts, geography, operations, forecast
  • Excel — one sheet per report, cover page, auto-filters, frozen headers, per-cell currency formats
  • PDF — branded cover with KPI cards and findings, embedded Unicode font so every currency symbol renders
  • CSV — BOM-prefixed, formula-injection guarded, spreadsheet-parseable
  • Presets — board pack, CRM upload, merchandising review, finance reconciliation, complete export
  • Exports match the on-screen date range and are never row-capped
  • Written report view at /reports/view — conclusion first, method last
Connection & security
  • WooCommerce app authorization — approve access in your own WordPress admin
  • No form or environment variable accepts a WooCommerce consumer key
  • HMAC-signed, self-contained state token binds the browser redirect to the server-to-server callback, so the two legs may land on different instances
  • Credentials verified against the store before being persisted
  • Pluggable durable storage — filesystem self-hosted, Redis on serverless
  • Preflight rejects non-HTTPS and non-routable callbacks before you approve
  • Optional password login, HMAC-SHA256 cookies verified in middleware
  • One-click disconnect wipes the key and every cached order
  • Customer phone numbers never reach the browser — the payload carries only whether someone is reachable
  • The gateway key is stored the same way and shown only masked

Complete feature list

Everything in the product, so nothing shipped is undocumented.

Analytics

Revenue — net and gross revenue · orders · average order value · units · discounts · shipping · tax · refunds · items per order · revenue per customer · cancellation rate · period-on-period comparison · day, week and month views · sparkline trends · generated findings ranked by urgency

Customers — RFM scoring · ten segments · five value tiers · predicted lifetime value · churn risk · revenue deciles · Pareto curve · Gini coefficient · recency-frequency plot · top-customer spotlight · high value, low value, at risk and rising cohorts · full ledger with expandable orders

Profiles — contact details · location · acquisition channel and device · RFM pips · revenue percentile · refunds · discounts used · products ranked by revenue · order value over time · complete order history

Acquisition — new versus returning revenue · full customer tables · channel attribution · first-time buyer analysis per channel · device breakdown · pages per session · time to second order with quartiles · monthly cohort retention · cumulative LTV curve · attribution coverage

Products — ABC classification · Pareto concentration · revenue, units, orders and distinct customers per product · price and velocity · refund rate per product · average rating · market-basket affinity by lift · category mix · best sellers by value and volume · slow movers · never-sold items

Inventory — days of cover · reorder points from lead time · suggested order quantities · out of stock, critical, low, healthy and overstocked states · revenue at risk · capital tied up · restock planner · draft purchase order export

Operations — full order register · status mix · basket-size distribution · payment method performance · day-by-hour trading heatmap · weekday performance · fulfilment timing

Forecast and geography — daily revenue projection · 95% confidence band · weekday seasonality shown explicitly · revenue, orders, customers and AOV by country, state and city

Messaging

Campaigns — audience builder with live reach · six goal presets · filters on segment, tier, recency, spend, orders, churn risk, country, account type, product bought and contactability · revenue and predicted value at stake · CSV export for email and ads platforms · campaign performance from UTM tags · coupon return on discount

WhatsApp — text, image and video · nine templates · eleven personalisation variables · per-customer product photos · catalogue product picker · coupon creation and attachment · dry run · test send · typed confirmation · opt-out list · paced sending with jitter · resumable jobs · automatic gateway recovery · live progress and stop control

Inbox — conversations matched to customers · order count and lifetime spend beside each · link to full profile · live reply polling · text, media and product replies · start a conversation from a number · unread counts · search across names and numbers · opt-out enforced

Platform

Reports — ten report types · Excel with formatted sheets, filters and currency formats · PDF with cover, KPIs and findings · CSV for pipelines · board pack, CRM upload, merchandising and finance presets · written report view · exports match the on-screen range and are never truncated

Interface — multiple stores with instant switching · command palette across customers, products and orders · keyboard navigation throughout · light and dark themes · persisted date ranges · sortable, searchable, expandable tables · optional password protection · honest empty states · partial-data warnings

Security — approval inside your own WordPress admin · no consumer key ever typed into a form · verification before saving · keys shown masked · phone numbers never sent to the browser · opt-outs enforced server-side · one-click disconnect wipes the key and every cached order


Feature tour

Dashboard

Headline KPIs against the equal-length previous window, revenue and orders on a single shared axis, findings ordered by urgency, top products and customers, payment mix, geography, and a day × hour trading heatmap.

Customers

RFM scoring mapped to ten segments, value tiers, predicted CLV, churn risk, and a Pareto view with a Gini coefficient. Top-customer spotlight, advanced filters, and the complete ledger with orders expandable in place.

Acquisition and cohorts

New versus returning revenue by month with full tables of each. Channel and device breakdowns from Order Attribution, median time to a second order, a retention triangle and a cumulative LTV curve.

Campaigns

Build an audience, choose a template, attach a coupon and a product, dry-run it, test it, then send. Plus campaign performance from utm_campaign and coupon return-on-discount.

Inbox

WhatsApp conversations with the customer behind each number, their order count and spend alongside, and a composer that sends text, media or a catalogue product.

Inventory

Days of cover, reorder points, suggested quantities, revenue at risk and capital tied up, with a CSV export shaped as a draft purchase order.

Products, orders and forecast

ABC classification and market-basket affinity; the full order register with payment and coupon performance; a projection with a 95% band.


Keyboard shortcuts

Key Action
⌘K / Ctrl K Open search and commands
/ Open search
? Show the shortcut list
⌘⇧R Re-sync from WooCommerce
⌘⇧L Toggle light and dark
g d Dashboard
g f Forecast
g c Customers
g a Acquisition
g h Cohorts and retention
g p Products
g i Inventory
g o Orders
g m Campaigns
g w Inbox
g r Reports
g s Settings

All shortcuts are ignored while you are typing into a field.


Small things

The details that do not fit a feature list but decide whether the thing is pleasant to use.

Interface

  • Date range and granularity persist across reloads, read through an external store rather than restored in an effect — so there is no flash of the wrong range on first paint
  • Tables sort, search, paginate, keep a sticky first column, and expand rows in place rather than navigating away
  • Tabular figures in every column that has to align; proportional elsewhere
  • Empty states explain why a section is empty rather than showing zero
  • Partial-history and truncated-pull warnings are surfaced, never hidden
  • Sidebar collapses; the layout is usable from 375px up
  • Light and dark are both designed, not auto-inverted

Search and keyboard

  • The command palette filters data before rendering, so 11,000 customers do not mount 11,000 nodes to hide most of them
  • Shortcuts are ignored inside inputs, textareas, selects and open comboboxes, so typing a search query never triggers navigation
  • g chords lapse after 1.2 seconds, so a stray g does not arm forever
  • ? and shift+/ both open the help sheet, because keyboard layouts disagree

Exports

  • CSV is BOM-prefixed so Excel opens UTF-8 correctly
  • Formula injection is guarded on text only — an earlier version turned every negative number into a string because - matched the guard
  • Dates export as yyyy-mm-dd, parseable by every spreadsheet
  • PDFs embed Geist, because the standard PDF encoding has no glyph and silently substituted a superscript one
  • Exports follow the on-screen date range, so a download can never disagree with the dashboard it came from

Correctness

  • Cohort cells that have not elapsed are blank, not zero
  • New and returning customer lists deliberately overlap; the alternative made the returning list empty on any full-history range
  • Product names, categories and customer names are HTML-decoded once at ingest, so no chart, table, PDF or CSV has to think about it
  • Money is formatted from the store's own currency, not a hard-coded symbol

Safety

  • Secrets are masked everywhere they are displayed and never returned to the browser after being saved
  • The WhatsApp test send cannot reach a customer — it only accepts a typed number
  • Generated coupon codes omit O, 0, I and 1
  • Duplicate phone numbers across customer records collapse to one recipient
  • Every skipped recipient is counted and categorised, so "sent 9,800 of 11,000" always has an explanation

Architecture

Four systems. Three run on infrastructure you control; the fourth is a model provider that never sees a customer.

graph TB
    subgraph store["WordPress host"]
        WC["WooCommerce<br/>REST API v3"]
        DB[("Store database<br/>orders · products<br/>customers · coupons")]
        WC --- DB
    end

    subgraph vps["Always-on server (VPS)"]
        OWA["OpenWA gateway<br/>NestJS"]
        ENGINE["Engine<br/>whatsapp-web.js / baileys"]
        AUTH[("Session credentials<br/>./data")]
        OWA --- ENGINE
        ENGINE --- AUTH
    end

    subgraph pulse["PulseCommerce (Vercel or Node)"]
        ROUTES["API routes"]
        LIB["lib/ — woo · analytics<br/>whatsapp · export · auth"]
        SSR["React pages"]
        ROUTES --- LIB
        LIB --- SSR
    end

    KV[("Redis or disk<br/>snapshot chunks · store config<br/>gateway config · broadcast jobs<br/>flow state · opt-outs")]

    CRON["Supabase pg_cron<br/>10min sync · daily flows · 5min recovery"]
    GROQ(["Groq<br/>counts and names only"])
    BROWSER["Browser"]
    WA(["WhatsApp"])
    CUST(["Customer phones"])

    WC -->|"GET orders, products,<br/>customers, coupons"| ROUTES
    ROUTES -->|"POST coupons<br/>the only write"| WC
    ROUTES <-->|"read + write"| KV
    ROUTES -->|"X-API-Key over HTTPS"| OWA
    ROUTES -->|"tool results:<br/>no phones, no emails"| GROQ
    CRON -->|"bearer CRON_SECRET"| ROUTES
    ENGINE <-->|"persistent socket"| WA
    OWA -->|"plugins answer inbound"| WA
    WA --> CUST
    SSR --> BROWSER
    BROWSER -->|"filters, never numbers"| ROUTES
Loading

Two properties hold by construction rather than by care:

  • The app never speaks to WhatsApp. It speaks to your gateway, which holds the socket. Move the gateway and nothing here changes but a URL.
  • The browser never receives a phone number. The analytics payload carries a hasPhone boolean. Numbers are resolved server-side at send time.
  • The model never receives one either. Its tools return counts, names and keys; a customer is addressed by key and the number is resolved at send time, so nothing it can read could appear in something it writes.
  • Nothing waits in a request. A broadcast paces itself over hours and a flow over days. Both keep their position in storage and are advanced by ticks — one driven by the browser, one by cron.

Inside the app

graph LR
    subgraph routes["app/api"]
        R1["analytics"]
        R2["customers/[key]"]
        R3["reports/export"]
        R4["auth/woo"]
        R5["settings"]
        R6["whatsapp/*"]
        R7["whatsapp/flows · menu"]
        R8["ai/chat"]
        R9["cron/flows"]
    end

    subgraph libs["lib"]
        W["woo/<br/>client · slim<br/>attribution · entities"]
        S["store/<br/>config · kv<br/>snapshot · snapshot-cache"]
        A["analytics/<br/>engine · customers · cohorts<br/>acquisition · products<br/>inventory · operations"]
        M["whatsapp/<br/>client · phone · templates<br/>recipients · broadcast<br/>flows · menu · opt-out · config"]
        AI["ai/<br/>tools · execute"]
        E["export/<br/>csv · xlsx · pdf"]
        AU["auth/<br/>session · pending"]
    end

    R1 --> S --> W
    R1 --> A
    R2 --> A
    R3 --> E --> A
    R4 --> AU
    R5 --> S
    R6 --> M --> S
    M --> A
Loading

analytics/ is pure: functions from a snapshot to numbers, with no I/O. That is what makes a date-range change cost milliseconds and makes every figure reproducible from the same input.

Layout

src/
├── app/
│   ├── page.tsx              Authorization page (the root)
│   ├── (app)/                Pages behind the sidebar shell
│   │   ├── dashboard/        Headline KPIs, trend, findings
│   │   ├── customers/        RFM, tiers, CLV, churn, [key] profiles
│   │   ├── acquisition/      New vs returning, channels, devices
│   │   ├── cohorts/          Retention triangle, LTV curve
│   │   ├── campaigns/        Audience builder + WhatsApp send panel
│   │   ├── inbox/            WhatsApp conversations
│   │   ├── products/         ABC, refund rate, affinity
│   │   ├── inventory/        Restock planner, reorder points
│   │   ├── orders/           Register, payments, coupons
│   │   ├── forecast/         Trend + seasonality projection
│   │   ├── reports/          Report builder and written report
│   │   └── settings/         Stores, WhatsApp gateway, data window
│   └── api/
│       ├── analytics/        Computes the full payload
│       ├── customers/[key]/  One customer with order history, on demand
│       ├── auth/woo/         start → callback → return
│       ├── reports/          Export generation
│       ├── settings/         Connection state, switching, data window
│       └── whatsapp/
│           ├── settings/     Gateway connection, verified before saving
│           ├── session/      Session state and pairing QR
│           ├── preview/      Dry run — resolves recipients, sends nothing
│           ├── test/         One message to a typed number
│           ├── broadcast/    Create, tick, cancel
│           ├── chats/        Conversations and replies
│           ├── products/     Catalogue search
│           ├── coupons/      List, and the one write in the app
│           └── opt-out/      Numbers excluded from every send
├── lib/
│   ├── woo/                  REST client, field trimming, slimming, attribution
│   ├── store/                Config, KV abstraction, snapshot cache
│   ├── analytics/            Pure engine: KPIs, customers, cohorts, acquisition,
│   │                         products, inventory, operations, forecast
│   ├── whatsapp/             Gateway client, phone normalisation, templates,
│   │                         recipient resolution, broadcast jobs, opt-outs
│   ├── export/               CSV, Excel and PDF builders, embedded fonts
│   └── auth/                 Signed sessions, signed authorization state
├── components/
│   ├── charts/               Chart primitives on a CVD-validated palette
│   ├── dashboard/            Stat strip, data table, filters, page states
│   ├── layout/               Sidebar, topbar, store switcher, command palette
│   ├── whatsapp/             Gateway settings, QR, send panel, product picker
│   └── ui/                   shadcn/ui
└── middleware.ts             Session gate

Scope

The store key is read_write, for exactly one reason: creating campaign coupons. WooCommerce offers no finer scope, so the narrowing is enforced in code — WooClient has one mutating method, createCoupon, reached by one endpoint. Nothing writes to orders, products, customers or settings. Set the scope back to read in api/auth/woo/start and re-authorize if you do not want generated coupons; every other feature is unaffected.


Data pipeline

One pull, cached three ways, then everything derived from it.

flowchart TD
    A["WooCommerce REST"] -->|"_fields trims<br/>900KB to 160KB per page"| B["Paginated pull<br/>3 connections, paced 120ms"]
    B -->|"retry with backoff<br/>on 0, 408, 429, 5xx"| B
    B --> C["slim()<br/>drop tax arrays, meta_data,<br/>image galleries — 81% smaller"]
    C --> D["decodeEntities()<br/>+ attachAttribution()"]
    D --> E["Snapshot<br/>orders · customers · products"]
    E --> F["gzip + 300KB chunks<br/>behind a manifest"]
    F --> G[("Shared cache<br/>Redis")]
    E --> H[("Disk / tmp<br/>60 min")]
    E --> I[("Memory<br/>10 min")]
    G --> J["computeAnalytics()<br/>pure, per request"]
    H --> J
    I --> J
    J --> K["Range + granularity"]
    K --> L["Payload<br/>hasPhone, never the number"]
Loading

Cache lookup order

flowchart LR
    REQ["Request"] --> M{"Memory<br/>fresh?"}
    M -->|yes| OUT["Serve"]
    M -->|no| INF{"Already<br/>in flight?"}
    INF -->|yes| JOIN["Await it"] --> OUT
    INF -->|no| D{"Disk<br/>fresh?"}
    D -->|yes| WARM1["Warm memory"] --> OUT
    D -->|no| S{"Shared<br/>fresh?"}
    S -->|yes| WARM2["Warm memory + disk"] --> OUT
    S -->|no| PULL["Pull from WooCommerce"] --> WRITE["Write all three"] --> OUT
Loading

Why a snapshot. Every metric — RFM, cohorts, affinity, forecast — needs the whole order history, not a page of it. Computing from one cached snapshot means a filter change costs milliseconds instead of a re-pull.

Why in-flight collapsing. A single page load fires several requests. Without it, a cold start would begin several identical multi-minute pulls at once.

Cache key. Hashed from the schema version, store URL and data window. Deliberately not the consumer key: WooCommerce issues a fresh one on every re-authorization, and keying on it once orphaned every cached copy at a stroke and forced a full re-pull that outlived a serverless request.

Chunks before manifest. The shared cache writes its chunks first and the manifest last, so a reader can never find a manifest pointing at chunks that do not exist yet.


Broadcast pipeline

A broadcast is a resumable job, not a long request.

sequenceDiagram
    participant B as Browser
    participant P as PulseCommerce
    participant K as Storage
    participant W as Gateway
    participant C as Customers

    B->>P: filter + message (never numbers)
    P->>P: recompute audience from snapshot
    P->>P: resolve phones · drop unreachable<br/>remove opt-outs and duplicates
    P-->>B: dry run — counts, reasons, masked sample
    B->>P: confirm the exact deliverable count
    P->>P: re-resolve, refuse if it changed
    P->>K: create job (recipients, cursor 0)
    loop one batch per tick
        B->>P: tick
        P->>K: read cursor
        P->>W: ensure engine live
        P->>W: send-bulk, up to 100
        W-->>C: paced sends with jitter
        P->>K: advance cursor, record batch
        P-->>B: progress
    end
Loading

Job states

stateDiagram-v2
    [*] --> sending: confirmed
    sending --> sending: tick hands over a batch
    sending --> completed: cursor reaches the end
    sending --> cancelled: stopped by the operator
    sending --> failed: gateway unrecoverable
    cancelled --> [*]
    completed --> [*]
    failed --> [*]
    note right of cancelled
        Batches already accepted
        by the gateway still go out.
    end note
Loading

Gateway session states

stateDiagram-v2
    [*] --> created
    created --> initializing: start
    initializing --> qr_ready: no stored pairing
    qr_ready --> ready: QR scanned
    initializing --> ready: pairing restored from disk
    ready --> stale: gateway process restarts
    stale --> ready: engine restarted automatically
    ready --> disconnected: stopped or logged out
    disconnected --> ready: start, pairing still on disk
    note right of stale
        Row says "ready", engineLoaded is false.
        Sends fail until the engine is restarted,
        which the app does by itself.
    end note
Loading

Why ticks. The gateway paces its own batches — a hundred recipients at four seconds apart occupies it for roughly seven minutes — which no serverless request can wait out. Each tick hands over at most one batch and returns, so closing the page pauses the job rather than breaking it, and whatever was already handed over still goes out.

Why recipients are resolved on the server. The browser sends a filter. Numbers are derived from the snapshot at send time, so no crafted request can address someone who is not a customer of the connected store, and the opt-out list cannot be routed around by the UI.

Why both readiness signals are checked. status is a persisted database value and engineLoaded is the live one. After a gateway restart the row still says ready while the engine is gone, and a send in that state fails for every recipient. Both must agree before anything is dispatched.


Flow pipeline

A broadcast is one message now. A flow waits days between steps, which no serverless request can sit through — so the waiting lives in storage and a scheduled job advances it.

sequenceDiagram
    participant V as Supabase pg_cron
    participant P as PulseCommerce
    participant K as Storage
    participant W as Gateway

    Note over V: once a day
    V->>P: POST /api/cron/flows (bearer CRON_SECRET)
    P->>K: read active flows and their state
    P->>P: resolve entry audience from the snapshot
    P->>K: enrol anyone new, skipping everyone seen before
    P->>P: drop anyone whose exit condition now holds
    P->>P: select enrolments whose dueAt has passed
    loop each due enrolment
        P->>W: send this step
        W-->>P: accepted
        P->>K: advance to the next step, due N days from now
    end
    P->>K: write state
    P-->>V: what each flow did
Loading

Time, not wakefulness, decides what sends. Every due date is stored. A tick that is skipped, retried or runs late therefore sends the same messages, once, whenever it does run — a missed run delays a step rather than losing it, and an enrolment advances only after its send returns, so it cannot double one.

Each wait is measured from the send, not from enrolment. If a run is four days late, step two is still three days after step one went out, not three days after the customer joined. Measuring from enrolment would fire two messages within seconds to catch up. There is a test for exactly this.

The tick is bounded. At most 500 enrolments and 60 sends per run, 5,000 in flight per flow. Whatever is left is picked up next time: a flow that enrols slowly is correct, just slower, whereas a tick that times out mid-send leaves state nobody can reason about.

The cron route is closed when its secret is unset. CRON_SECRET missing returns 503 rather than running open — the failure mode of a missing environment variable must never be "anyone on the internet can trigger the sends".


Assistant pipeline

The loop is the safety model. Reads run; actions stop and wait for a person.

sequenceDiagram
    participant B as Browser
    participant P as PulseCommerce
    participant G as Groq
    participant K as Snapshot
    participant W as Gateway

    B->>P: question
    P->>P: pick the tools this question could need (max 8)
    P->>G: question + those schemas

    loop up to 4 rounds
        G-->>P: tool call
        alt a read
            P->>K: run it
            K-->>P: counts and names, no phones
            P->>P: truncate to 1600 chars
            P->>G: result, loop continues
        else an action
            P-->>B: proposal, unexecuted
            Note over P,B: the loop ends here
        end
    end

    G-->>P: answer
    P-->>B: answer + what it read

    Note over B: a person reads the card
    B->>P: approve
    P->>W: the ordinary endpoint, with its own guards
Loading

The model holds no power of its own. An action tool is never executed server-side. It comes back as a card, and approving it calls the same REST endpoint the matching screen calls — so the assistant is a route to those endpoints, not a way around the checks they already have.

A proposal ends the turn even if reads were requested alongside it. Running them and looping would let the model revise or withdraw its own proposal before anyone saw it, and the point is that a person sees exactly what was proposed.

Invented URLs are stripped, not merely discouraged. Any imageUrl or productUrl absent from this turn's tool results is removed before the proposal is shown, and the removal is stated on the card. Told plainly not to invent a link, a smaller model still proposed https://example.com/… without calling the catalogue at all — a prompt is a request, this is a rule.

Three things keep a question inside a metered budget: only relevant tools are sent, results are truncated with the truncation announced, and optional parameters accept null (widened via type, never anyOf — unions made the model fail to produce a call at all).


Inbound pipeline

Everything else here reaches out. This is the one path that starts with the customer, and it does not run in this application at all.

sequenceDiagram
    participant C as Customer
    participant W as WhatsApp
    participant G as Gateway
    participant PL as chat-flow plugin
    participant S as Plugin storage

    C->>W: "hi"
    W->>G: inbound message
    G->>PL: message:received hook

    alt exactly the trigger word
        PL->>S: start this chat at the menu root
        PL-->>C: greeting + numbered options
    else a reply while a menu is open
        PL->>S: read where this chat is
        S-->>PL: current node
        PL-->>C: that option's reply, or the next submenu
    else anything else
        PL-->>G: ignored, left for a person
    end

    Note over S: state expires after 15 minutes
Loading

It runs on the gateway, not here, which is why it answers at three in the morning whether or not this application is running.

The trigger is matched exactlyinput.toLowerCase() === trigger — so hi opens the menu while this and hi there do not. Left empty it matches any text message, which on a live business line means every customer gets a menu instead of a person; the editor warns rather than quietly accepting it.

State expires after fifteen minutes, so a stray 1 the next day is never read as a menu choice.


Quick start

git clone https://github.com/nitheeshdr/PulseCommerce.git
cd PulseCommerce
npm install
cp .env.example .env.local
npm run dev

Open http://localhost:3000. The app runs immediately, but every page shows "Connect your WooCommerce store" until one is authorized.

Requirements: Node 20+ (developed on 22) and a WooCommerce store with the REST API enabled. WordPress permalinks must not be "Plain", or the REST API returns 404.


Connecting a store

Connection uses WooCommerce's own app authorization endpoint (/wc-auth/v1/authorize). You approve access inside your own WordPress admin; WooCommerce issues the key and delivers it to this app.

There is deliberately no form that accepts a consumer key, and no environment variable for one. A merchant pasting a secret into a third-party form is exactly the failure mode that endpoint exists to remove.

sequenceDiagram
    participant B as Your browser
    participant A as PulseCommerce
    participant W as Your store

    B->>A: Settings, authorize, enter store URL
    A->>A: Create signed state token
    A-->>B: Redirect to the store's authorize page
    B->>W: Log in to WP admin, approve access
    W->>A: POST consumer key + secret (server to server)
    A->>A: Verify token, verify key, save
    W-->>B: Redirect back
    A-->>B: Connected, dashboard
Loading

The one requirement

That server-to-server POST means this app must be on a public HTTPS address:

Requirement Why
HTTPS WooCommerce refuses to hand credentials to a plain-HTTP callback.
Publicly resolvable localhost points your store's server at itself, not at you.

Both are validated before you are redirected, so you can never get stuck approving an app that could not have received the result.

Local development

npm run dev:https                                   # https://localhost:3000
cloudflared tunnel --url https://localhost:3000     # or: ngrok http https://localhost:3000

Put the public address into .env.local as APP_URL and restart.

APP_URL is auto-detected on Vercel from VERCEL_PROJECT_PRODUCTION_URL.


WhatsApp campaigns

Broadcasts go through a self-hosted OpenWA gateway that you run. PulseCommerce is only a client of its REST API.

What you need

A host that can keep a process running continuously:

Works Why
VPS or cloud server Yes Docker, persistent volumes, a process that stays up
Shared hosting No Processes are recycled and the auth directory does not survive
Vercel / serverless No No long-lived process at all

A WhatsApp session is a live connection. If the process dies and its data directory is wiped, the number unlinks and a broadcast that runs for hours never finishes.

Setting it up

  1. Deploy OpenWA behind HTTPS on your server (Docker Compose is its supported path).
  2. Create a session and link a dedicated number — see the warning below.
  3. Create an API key with the operator role. Sending is all this needs.
  4. In Settings → WhatsApp gateway, enter the URL and key, and confirm the country code.

Or set it on the host instead, which survives a redeploy and a cleared store:

WHATSAPP_API_URL=https://wa.yourdomain.com
WHATSAPP_API_KEY=...            # operator role
WHATSAPP_DIAL_CODE=91           # assumed when a number has no country code
WHATSAPP_SESSION_ID=...         # optional; the only session is adopted if omitted

If no number is linked, Settings shows the pairing QR inline. It is proxied through this app, so the gateway key never reaches a browser page.

Sending

On Campaigns, build an audience, then compose below it. The order of the controls is the safety model:

  1. Check who this would reach — a dry run. Resolves the real list and sends nothing, reporting how many were dropped and why, a masked sample, and the message as the first real recipient would see it.
  2. Test — one message to a number you type. It cannot reach a customer.
  3. Send broadcast — requires typing the deliverable count. The server re-resolves the audience and refuses if it changed.

Templates and variables

Nine templates, each resolving against the customer's own history. Variables: {{name}}, {{product}}, {{product_url}}, {{category}}, {{last_order}}, {{orders}}, {{spend}}, {{store}}, {{coupon}}, {{coupon_value}}.

The product chosen is the one they have spent the most on, not the most recent — a one-off small purchase should not become the thing a reorder message is built around. A variable with no value is removed and the sentence tidied, so nobody receives a literal {{product}} or a dangling comma.

A campaign product picked from the catalogue overrides that for everyone, and supplies the photo.

Choosing who receives it

Three ways, on the Campaigns page:

Mode Who it reaches
Current filters Whatever the audience builder above resolves to
Everyone Every customer in the selected date range
Choose customers A specific list you tick, searchable by name or email

"Everyone" drops the filters deliberately rather than reusing whatever is on screen — sending to all customers should not quietly inherit a churn-risk filter somebody set ten minutes earlier.

A chosen list narrows the current filter rather than replacing it, so somebody unreachable or opted out is still excluded. Selections travel as customer keys, never phone numbers: a key only means anything against the connected store's own data, the server still resolves the number itself, and the opt-out list is applied afterwards regardless.

Templates

Ten, each written for a specific job rather than as a blank box:

Template What it says When to send it
Reorder Names the product they bought, links to it Their usual reorder gap has passed
Category arrivals New items in the range they already shop You add stock to a category
Win back References their product and last order date At Risk or Hibernating segments
Second order Asks how they got on, invites a repeat One-time buyers, still recent
VIP thank you Names their order count, invites a reply Champions and top-tier customers
Review request Asks for feedback on what they buy Repeat customers with a recent order
Coupon + product A discount tied to their own product Lapsed customers worth converting
Coupon only A store-wide discount offer Any audience, no clear favourite
Announce one product Its photo, your caption, and the buy link A launch, a restock, a push on one item
Announcement Your own words, greeting still personal Anything else

Templates are edited freely before sending. No approval process applies, unlike Meta's official API.

Variables

Each resolves per recipient, at the moment of sending:

Variable Resolves to
{{name}} First name, or nothing for a guest checkout with no usable name
{{product}} The product they have spent the most on
{{product_url}} That product's permalink from WooCommerce
{{product_image}} That product's first image
{{category}} The category that product belongs to
{{last_order}} When they last ordered, e.g. "14 March"
{{orders}} How many orders they have placed
{{spend}} What they have spent in total, formatted in the store's currency
{{store}} Your store name
{{coupon}} The coupon code attached to the campaign
{{coupon_value}} What it is worth, e.g. "10% off" or "₹150 off"

Spent-the-most-on, not most-recent. A one-off small purchase should not become the thing a reorder message is built around.

Unresolved variables collapse. A variable with no value for someone is removed and the sentence tidied afterwards — [^\S\n]{2,} to a single space, , to ,, a line left as a bare colon deleted — so nobody receives a literal {{product}}, a dangling comma, or a label with nothing under it.

Sending mechanics

Spacing ~4s between messages, plus the gateway's own 0–2s jitter
Batch size 100 recipients per submission (the gateway's maximum)
Duration A full base runs for hours, deliberately
Interruption Closing the page pauses; reopening resumes from the cursor
Gateway restart Detected and recovered automatically, costing one batch of delay

Every excluded recipient is counted and categorised — no phone on file, an unreadable number, a duplicate shared with another record, or an opt-out — so "sent 9,800 of 11,000" always has an explanation.

Pacing is the point. Sending thousands of messages in minutes is the single most reliable way to have a number restricted.

Coupons

Pick an existing WooCommerce coupon, or generate one: a code, percentage or fixed amount, an expiry, one use per customer, and — when a campaign product is chosen — restricted to that product. Generated codes avoid O/0 and I/1, because they get read off a phone screen and typed at a checkout.

Codes that have expired or hit their usage limit are listed but cannot be selected. Sending a code that will be refused is worse than sending none.

Phone numbers

Checkout fields are free text, and numbers often carry no country code. The default country code decides what a bare national number becomes, which makes it the single setting most worth checking — a wrong value sends to the wrong country.

Anything unreadable as a subscriber number is dropped and counted, never guessed. Duplicates across customer records are collapsed so a household is messaged once. Numbers never reach the browser.

Buttons, product cards and link previews

A self-hosted gateway cannot send tappable buttons or product cards. Those need pre-approved templates on Meta's Cloud API; WhatsApp withdrew them from unofficial clients. OpenWA marks its catalog endpoints "not supported by any engine", and a live gateway answers 501.

The closest thing is WhatsApp's link preview, a tappable card built from the page itself. Two things affect it:

  • Do not attach a photo — media suppresses the preview.
  • Your product pages need og:image — most WordPress SEO plugins add it.

Use a dedicated number. OpenWA connects through reverse-engineered clients rather than Meta's official API, so there is a real risk of restriction and no appeal path. Do not link the number your business runs on. Messaging your own past customers is the safest workload; cold-blasting strangers is what gets numbers banned.


Automated flows

A broadcast is one message to a list, now. A flow is a sequence that runs itself over days. Build one at /flows.

entry audience  ->  step 1  --wait 3 days-->  step 2  --wait 7 days-->  step 3
                      |                         |                         |
                      +-------------------------+-------------------------+
                                        |
                              exits the moment they order

Customers join as they qualify. The entry filter is re-evaluated every run, so someone who lapses next month enters next month. A flow keeps working rather than sending once to whoever matched on the day it was built. Nobody enters twice for the life of the flow — every enrolled key is remembered.

They leave the moment it works. With exitOn: "ordered", a customer whose order count rises above what it was when they joined stops receiving the rest. Compared against the count at entry rather than a date, so there is no timezone or snapshot-lag question to get wrong.

Flows are created as drafts. Designing a sequence and starting to send it to thousands of people are two decisions.

Steps and the entry filter are immutable once created. People are already part-way through; changing step 3 under someone who has had steps 1 and 2 gives them a sequence nobody designed. Build a new flow instead.

Testing a flow without touching a customer

Set a test number on a flow and it ignores its entry audience entirely — every step goes to that one number. It is stored on the flow rather than being a runtime toggle, so a sequence built as a test cannot later be started against an audience by accident, and the list badges it as a test flow.

The opt-out list still applies, and a number that cannot be parsed yields no recipient at all: a mistyped test sends nothing rather than sending somewhere unintended.

Scheduling

The tick does not run on Vercel Cron — this project has none configured. It runs on Supabase pg_cron + pg_net: a trigger_app_job(path) SQL function (supabase/migrations/20260811170000_cron.sql) reads the app's base URL and CRON_SECRET from Supabase Vault and POSTs to this app directly from the database, on a schedule that lives in cron.job rather than in vercel.json. Advancing flows is scheduled once daily, at 04:30 UTC — the same reasoning as before still holds: waitDays is the real granularity a flow gets, since a step still only goes out on the first run after it comes due.

The same mechanism runs two other schedules: syncing every connected store every 10 minutes (/api/cron/sync), and abandoned-checkout recovery every 5 minutes (/api/cron/abandoned-checkouts, see Abandoned checkout recovery) for stores that turned it on. All three share the same trigger_app_job helper and the same CRON_SECRET bearer check on the app side — inspect or change any of them with select * from cron.job; against the Supabase database, not by editing vercel.json.

CRON_SECRET must be set in both the app's environment and Supabase Vault (as the cron_secret secret) for these to authenticate. Unset on either side, the route returns 503 and nothing advances.


Abandoned checkout recovery

WooCommerce creates a real order — status pending, on-hold or failed — the instant someone starts checkout, before payment finishes. There is no "cart" resource in WooCommerce's REST API, so that order is the earliest signal reachable from outside WordPress, and it already carries everything a reminder needs. Turn it on at /abandoned-checkouts.

Checked every 5 minutes, live against WooCommerce rather than the cached snapshot — the regular sync runs every 10 minutes and is close, but this reads directly so a 30-minute window is never at the mercy of sync timing. Only stores that turned the toggle on are ever polled.

An order becomes eligible once it has sat pending for 30 minutes, and stops being reconsidered after 24 hours. Turning the toggle on starts a boundary at that exact moment (abandoned_checkout_enabled_at): only orders placed after are ever considered, so an existing backlog of old pending orders is never messaged as a batch the instant the switch flips. Every order is reminded at most once — the opt-out list and an unreadable phone number both produce a skip, not a send, recorded in the same ledger.


Gateway plugins and the menu bot

A self-hosted OpenWA gateway takes installable extensions, which run inside the gateway rather than in this application. They are relevant here because one of them closes the gap this app cannot: answering an inbound message.

Everything in PulseCommerce is outbound. Flows and broadcasts send; the inbox lets a person reply. Nothing replies automatically, because nothing here is running when a customer messages at 3am.

The menu bot

chat-flow answers immediately with a numbered menu:

customer sends "hi"
        |
        v
  1  Order        ->  shop link, order tracking
  2  Contact us   ->  how to reach a person
  3  Feedback     ->  invitation to just type it

Menus nest to any depth. State is per conversation and expires after 15 minutes, so a stray 1 the next day is never read as a menu choice.

Set a trigger word. The match is exact — input.toLowerCase() === trigger.toLowerCase() — so hi opens the menu while this and hi there are ignored. Left empty, the trigger matches any text message, which on a real business line means every customer gets a menu instead of a person.

What else is available

Plugin What it does
chat-flow Numbered menu bot, stateful per conversation
after-hours Away message outside configured business hours
faq-bot Keyword-matched auto-replies
http-action Calls an HTTP endpoint on an inbound message
voice-transcription Transcribes voice notes (beta; needs a paid API)
group-translate Translates group messages
gsheets-logger Logs messages to Google Sheets
chatwoot-adapter Bridges to a Chatwoot helpdesk
typebot-connector Bridges to Typebot
supabase-otp-hook Sends Supabase auth OTPs (beta)

Install from the gateway's catalogue, which indexes the plugin repository:

curl -X POST "$GATEWAY/api/plugins/install-url" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://github.com/.../chat-flow.zip"}'

Installed is not enabled. A plugin lands inert and only runs once enabled — which matters, because four of these reply to customers automatically. Configure before enabling:

curl -X PUT  "$GATEWAY/api/plugins/chat-flow/config" ... # menu tree
curl -X POST "$GATEWAY/api/plugins/chat-flow/enable" ... # now live

What the gateway cannot do

No buttons and no product cards — OpenWA drives a reverse-engineered client, not Meta's Business API, and those are Business API features. A menu is numbers typed as text, and a flow branches on free text rather than tapped replies.


The assistant

/assistant answers questions about the store and drafts messages, through Groq. The interesting part is not that it can read things; it is what it is prevented from doing.

you ask ──► model ──► read tool  ──► executed at once, result fed back, loop continues
                 └──► action tool ──► NOT executed
                                       │
                                       ▼
                              proposal card in the UI
                                       │
                                  you approve
                                       │
                                       ▼
                          the ordinary REST endpoint,
                          with the guards it already has

It reads freely. KPIs, segments, top customers, products, stock risk, audience sizes, flows, the menu bot, gateway status — all through the same functions the screens use, so it cannot quote a figure the dashboard disagrees with.

It proposes; it never performs. Sending a test message, toggling the menu bot, starting or pausing a flow, rewriting the menu. Each returns to the browser as a card. Approving calls the endpoint the matching screen calls — the assistant is a route to those endpoints, not a way around them.

There is no broadcast tool. Deliberately, and there is a test asserting it stays that way. Sending to an audience is the one action that reaches thousands of people irreversibly, and no phrasing of a prompt should get near it. It starts from Campaigns, by a person, behind a typed confirmation.

Phone numbers and email addresses are in nothing it can read. The rest of the app keeps them server-side; handing them to a model that then writes them into a reply would be the one place that rule quietly stopped holding.

What it can propose

A test message to a number you type · a message to one named customer · a message to a list of customers it has just read for you, every recipient named on the card · turning the auto-reply menu on or off · rewriting that menu · starting or pausing a flow · generating a PDF, Excel or CSV report over any date range.

Approving a customer send resolves the recipients server-side and uses the count from the platform's own dry run, not from how many names the model listed — so unreachable and opted-out customers are excluded exactly as on the campaigns screen. Batches are capped at 25.

Conversations

Listed beside the chat, reopenable, deletable, and kept in localStorage rather than on the server: they hold the operator's questions about their own store and nothing the server needs, so there is no second copy of business data to secure. The cost is that history does not follow you to another machine.

Summaries on the analytics pages

Dashboard, customers, products, inventory, acquisition and forecast each carry a plain-English read of what is on screen, answered through these same tools so a summary cannot disagree with the figures above it.

It runs on a button, not on page load. Every summary is a model call against a metered key with a daily cap; a dashboard that spends quota each time somebody glances at it exhausts the allowance by mid-morning, and the assistant is then unavailable exactly when it is wanted.

Choosing a model, and staying inside the free tier

openai/gpt-oss-120b by default, GROQ_MODEL to override, falling back to llama-3.3-70b-versatile when a daily cap is hit — the response reports which model answered, so nobody wonders why quality moved.

Not a Llama model by default, and the reason is tool calling rather than prose: with sixteen tools in play the Llama models intermittently emit their text call format, <function=find_product{…}</function>, which Groq rejects outright. gpt-oss produced a clean call every time under the same load.

Three things keep a question inside the free tier's per-minute token budget:

Action tools are sent only when wanted Most questions are analytics and can never need one. An analytics question carries nine schemas instead of sixteen.
Tool results are truncated To 1,600 characters, and the truncation is announced so the model does not quote a total from a partial list.
Optional parameters accept null Models say "not narrowing this" with null far more often than by omitting a key, and Groq validates the call before we see it. Widened via type, never anyOf — unions made the model fail to produce a call at all.

Temperature 0.2: this answers questions about a real business from real figures, and inventiveness is not wanted. Without GROQ_API_KEY the route returns 503 and the screen says it is unavailable.

Built with @tanstack/ai and the shadcn bubble, message, message-scroller, marker and attachment components.


Multiple stores

Authorize as many as you like. Each keeps its own credentials, data window and snapshot cache. Switch from the sidebar or Settings; re-authorizing a store you already have updates its key in place rather than duplicating it. Switching is a pointer change — the next request reads a different cache rather than re-pulling.


Optional password protection

AUTH_SECRET=$(openssl rand -hex 32)   # signs the session cookie
APP_PASSWORD=something-long

With both set, every route redirects to /login. Sessions are HMAC-SHA256 cookies verified in middleware via Web Crypto, so the same code runs on Edge and Node. Completing the WooCommerce authorization also establishes a session — proving you can approve the store is proof of access.


Environment variables

Variable Required Purpose
APP_URL to connect Public HTTPS address WooCommerce delivers credentials to. Auto-detected on Vercel.
AUTH_SECRET to connect Signs the authorization state token and session cookie.
APP_PASSWORD for login Set alongside AUTH_SECRET to require a password.
KV_REST_API_URL on serverless Redis endpoint. Vercel KV and Upstash both provide it.
KV_REST_API_TOKEN on serverless Token for the above. UPSTASH_REDIS_REST_* also accepted.
SNAPSHOT_CACHE_MINUTES no How long a snapshot stays warm. Default 10.
WHATSAPP_API_URL no Gateway base URL. Takes the connection out of the UI.
WHATSAPP_API_KEY no Gateway API key, operator role.
WHATSAPP_SESSION_ID no Which session to send from. Adopted automatically if omitted.
WHATSAPP_DIAL_CODE no Country code for numbers stored without one, e.g. 91.
WHATSAPP_SEND_DELAY_MS no Pause between messages. Default 4000; gateway floor 1000.
CRON_SECRET for flows Authorises the scheduled flow tick. Unset closes the route rather than opening it.
GROQ_API_KEY for the assistant Enables /assistant. Unset returns 503 and the screen says so.

WooCommerce credentials are never environment variables — they arrive only through the authorization flow.


Reports and exports

Ten report types, as Excel (formatted sheets, cover page, auto-filters), PDF (branded cover, embedded font so renders), or CSV (bare numbers, parseable dates, BOM-prefixed, formula-injection guarded).

Exports use the date range on screen, so a downloaded report can never silently disagree with the dashboard it came from, and are never row-capped except in PDF, which prints the columns that fit a page and says so.


How the metrics are defined

Net revenue — order total less tax, shipping and refunds, for orders in completed, processing or on-hold. Cancelled, failed and pending are excluded from revenue but counted in cancellation rates.

Customer identity — guest checkouts carry no customer ID, so buyers are keyed on billing email. Someone using two addresses appears twice.

Two different repeat rates, reported separately: returning customer rate (active in the period, bought before it) and repeat rate in period (bought more than once inside it). On a 30-day window these differ by an order of magnitude.

New and returning lists overlap by design. A customer acquired in the period who buys again is genuinely both.

RFM scores are quintiles within your own base, not absolute thresholds.

Predicted CLV projects the observed order rate twelve months forward, discounted by churn risk. It is an estimate, and the UI says so.

Days of cover is stock divided by units sold per day over the range.

Order Attribution is core WooCommerce from 8.5; earlier orders carry none, and the coverage percentage is reported rather than hidden.


Performance

Against a production store of roughly 20,000 orders and 11,000 customers:

Measure Result
Cold instance, empty memory and disk 3.5 s
Warm request 0.7 s
Deployed production request ~2 s
Full-year customer payload, on the wire 911 KB
Cached snapshot 2.0 MB gzipped

Payload trimming. _fields does not reach nested arrays, so every line item arrived carrying its tax breakdown, meta_data and a full image object. Removing what the engine never reads cut the payload 81%.

Shared caching. The snapshot is cached where every instance can see it, gzipped and chunked behind a manifest, chunks written before the manifest so a reader never sees a partial set. A per-instance cache meant each cold start re-pulled the whole history — slow, and enough traffic that the store's security layer began refusing requests.

Order history on demand. Customer records ship without their orders, which is half a record's weight. That is what lets the ledger list every customer.

Paced fetching. Orders paginate three connections wide with a pause between batches. Transient failures retry with exponential backoff.


Design system

Black, white and one blue — #2f66e8, converted to oklch to match the rest of the tokens. A single accent means anything blue is deliberate: a link, a primary action, a selected row, never decoration. Dark mode uses a lighter step of the same hue, because #2f66e8 on near-black does not carry enough contrast for text sitting on it.

Chrome is monochrome by intent, so colour is reserved for data and genuine state. Charts use an eight-slot categorical palette validated for colour-vision deficiency separation, a normal-vision distinctness floor, lightness banding and contrast — in both modes, with dark steps selected for the dark surface rather than flipped.

  • No dual-axis charts. Two measures sharing a plot share one scale.
  • Sequential ramps are single-hue; diverging pairs use a neutral midpoint.
  • Legends always present for two or more series, so identity is never colour alone. Status colours ship with an icon and a label.
  • Unelapsed cohort cells are blank, not zero.
  • Geist and Geist Mono, tabular figures in columns that must align.

Deployment

npm run build
npm run start

Set APP_URL, plus AUTH_SECRET and APP_PASSWORD to gate the dashboard.

Serverless

Read-only filesystem, so provision Redis:

KV_REST_API_URL=https://your-store.upstash.io
KV_REST_API_TOKEN=...
AUTH_SECRET=...

Without it the app still runs and says the deployment cannot save a connection rather than failing mid-flow. The snapshot cache is shared through the same Redis, so cold starts read it rather than re-pulling.

The WhatsApp gateway cannot live here. It needs a process that stays up.


Troubleshooting

"Your store cannot reach this app"APP_URL points at localhost or a private address. The callback is a server-to-server POST. Use a tunnel.

"This deployment cannot save a connection" — serverless with no Redis. Set KV_REST_API_URL and KV_REST_API_TOKEN, then redeploy.

"AUTH_SECRET is not set" — generate one with openssl rand -hex 32.

"WooCommerce never delivered the key" — check APP_URL is publicly reachable and nothing (WAF, firewall, basic auth) blocks the POST.

REST API returns 404 — WordPress permalinks are "Plain".

"The store key is read-only" on coupon creation — the key predates the scope change. Reconnect the store in Settings to re-approve it.

WhatsApp session keeps dropping / shows qr_ready — the host cannot keep a process alive or is not persisting the gateway's data directory. Move it to a VPS.

"The session cannot send right now" — status and engine disagree, which happens after a gateway restart. The app restarts the engine automatically; if it persists, the pairing itself is gone and the QR needs re-scanning.

Everyone dropped as "unreadable" — the default country code is empty or wrong.

No attribution data — WooCommerce below 8.5, or Order Attribution disabled.

Inventory page empty — stock management is not enabled on the products.


Scripts

npm run dev          # http://localhost:3000
npm run dev:https    # https://localhost:3000, needed for the authorize flow
npm run build        # production build
npm run start        # serve the production build
npm run lint         # eslint
npm run typecheck    # tsc --noEmit
npm test             # Playwright end-to-end suite
npm run test:ui      # the same suite, interactively

Tests

The suite runs against a production build with no store connected and no gateway configured — the state a fresh clone is in, and the one most likely to break unnoticed. Nothing touches a real store or sends a message; the config blanks any Redis credentials from .env.local so a developer's own store cannot be reached from a test run.

What it asserts:

  • Every endpoint refuses work it cannot do, rather than half-attempting it — a missing store is a 409, not a crash or an empty dashboard
  • A broadcast without a confirmation count is rejected
  • An image message with no media and no product is rejected
  • The audience schema exposes no way to pass phone numbers directly
  • Every route in src/app/api appears in the OpenAPI document
  • Every mermaid diagram in this README parses, in a real browser with the library GitHub renders with

That last one exists because a diagram here once shipped broken: a label contained a semicolon, mermaid read it as the end of a statement, and nothing failed — the markdown was valid, the app built, lint passed, and GitHub printed a parse error where the diagram should have been.

API reference

The API is described by an OpenAPI 3.1 document at /api/openapi and rendered as Swagger UI at /api-docs, served from the bundle rather than a CDN so the reference works behind a firewall and is pinned to the version in the lockfile. "Try it out" is disabled: every route either needs a connected store or changes real state, and a docs page is no place to fire a live broadcast. It is written by hand rather than generated: these are Next.js handlers with Zod validation inside them, which no generator reads faithfully, and a generated document that drifts is worse than one somebody keeps honest. CI fails if a route exists that the document does not describe.

Continuous integration and deployment

.github/workflows/ci.yml runs on every push and pull request: typecheck, lint, the end-to-end suite with a Playwright report uploaded on failure, and the API-documentation coverage check.

Deployment is Vercel's, triggered by the push rather than by the workflow. What CI adds is a check that what Vercel published actually serves: when Vercel reports a deployment ready, a smoke job runs a separate suite against that deployment's URL.

Smoke tests are kept apart from the main suite deliberately. The main suite asserts the empty state — no store, no gateway — which is right for a fresh clone and wrong for production, where a store is connected. The smoke suite asserts only what must be true of any live deployment: the app serves, the API description is available, settings answers, analytics either returns data or says no store is connected, and no phone number appears in the analytics payload.

Run it against any deployment yourself:

BASE_URL=https://your-deployment.vercel.app npx playwright test --project=smoke


Built by

Setups Works

setups.works nitheesh rajendran

Self-hosted · your data and your WhatsApp number stay on your infrastructure

Licensed under the MIT License

Releases

Packages

Contributors

Languages