[object Object]

← back to Wpb Sales Dashboard

add secured idempotent /webhook/sale receiver + .env loader + docs

cf0832cb28a7a3813cbba8c19ad175a094ab5c63 · 2026-07-08 15:23:25 -0700 · Steve

Files touched

Diff

commit cf0832cb28a7a3813cbba8c19ad175a094ab5c63
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 8 15:23:25 2026 -0700

    add secured idempotent /webhook/sale receiver + .env loader + docs
---
 README.md | 34 ++++++++++++++++++++++++++++++++++
 server.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 94 insertions(+)

diff --git a/README.md b/README.md
index 44ddfdc..ee85e59 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,40 @@ A marketplace webhook can POST the same shape later — the manual form and the
 webhook write identical rows. Fields: `marketplace` (key from marketplaces.json),
 `title`/`design`, `units`, `gross`, `fee`, optional `net`/`date`/`orderId`.
 
+## Inbound sales webhook  `POST /webhook/sale`
+
+A **secured, idempotent receiver** for automated sales. Token-gated (NOT basic-auth,
+so external senders work) via the `WEBHOOK_TOKEN` in `.env` (gitignored). Send it in
+the `X-Webhook-Token` header or `?token=` query param.
+
+```sh
+curl -s -X POST http://127.0.0.1:9812/webhook/sale \
+  -H "X-Webhook-Token: $WEBHOOK_TOKEN" -H 'Content-Type: application/json' \
+  -d '{"marketplace":"etsy","listing":"Greige Carouse No.57710","order_total":149,"commission":9.8,"receipt_id":"R-88231"}'
+```
+
+- **Field aliases** are normalized, so you can forward a source's native field names:
+  `gross|total|amount|order_total|price`, `fee|commission|marketplace_fee`,
+  `title|listing|product|pattern`, `orderId|order_id|receipt_id|transaction_id`, etc.
+- **`marketplace` must be a known key** (`spoonflower|etsy|creativemarket|redbubble|patternbank|society6`) — a webhook can't invent a shop.
+- **Idempotent:** a repeat with the same `marketplace` + `orderId` returns `{duplicate:true}` and is NOT double-counted (safe for webhook retries).
+
+### Wiring the last hop (marketplace → this receiver)
+
+**None of these POD marketplaces push a native sales webhook to third parties** —
+so the last hop is one of:
+
+1. **Zapier / Make** — trigger "new sale confirmation email in Gmail" (or the
+   platform's own trigger where one exists) → action "Webhook POST" to
+   `https://<host>/webhook/sale?token=…` mapping the email fields to the aliases above. *(Recommended — works for every platform via the order-confirmation email.)*
+2. **Etsy poller** — Etsy has a real API but it's **poll-based** (pull `receipts`,
+   no push). A small cron that pulls new receipts for shop `SteveAbramsStudios` and
+   POSTs each here would make Etsy fully automatic — needs Etsy OAuth for that shop.
+3. **Manual / CSV** — paste a sale via the dashboard form, or `curl` a row (above).
+
+The receiver is real, secured infrastructure; the platforms simply give no
+push option, so a forwarder (Zapier/email/poller) bridges the last hop.
+
 ## Run
 
 ```sh
diff --git a/server.js b/server.js
index 81d1764..fd2ab09 100644
--- a/server.js
+++ b/server.js
@@ -24,6 +24,15 @@ const fs   = require('fs');
 const path = require('path');
 const os   = require('os');
 
+// --- minimal .env loader (zero-dep; loads WEBHOOK_TOKEN etc. without dotenv) ---
+try {
+  const envRaw = fs.readFileSync(path.join(__dirname, '.env'), 'utf8');
+  for (const line of envRaw.split('\n')) {
+    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+    if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+  }
+} catch { /* no .env — fine */ }
+
 const PORT = process.env.PORT || 9812;
 const ROOT = __dirname;
 const HOME = os.homedir();
@@ -42,6 +51,40 @@ const LEDGER      = path.join(ROOT, 'data', 'sales-ledger.jsonl');
 const AUTH_USER = process.env.DASH_USER || 'admin';
 const AUTH_PASS = process.env.DASH_PASS || 'DW2024!';
 
+// ---- webhook shared secret (inbound sales receiver; NOT basic-auth) -----------
+const WEBHOOK_TOKEN = process.env.WEBHOOK_TOKEN || '';
+
+// Known marketplace keys (from config) so a webhook can't invent shops.
+function marketplaceKeys() {
+  const cfg = readJSON(CFG_MARKETS, { marketplaces: [] }).marketplaces || [];
+  return new Set(cfg.map(m => m.key));
+}
+// Map many possible source field names onto one ledger row. Sources vary wildly
+// (Etsy receipt, Zapier email-parse, a marketplace CSV row) — normalize aliases.
+function normalizeSalePayload(b) {
+  const pick = (...keys) => { for (const k of keys) if (b[k] != null && b[k] !== '') return b[k]; return undefined; };
+  const marketplace = String(pick('marketplace', 'shop', 'channel', 'platform', 'source') || '').toLowerCase().trim();
+  const gross = n(pick('gross', 'total', 'amount', 'order_total', 'grand_total', 'price', 'revenue'));
+  const fee   = n(pick('fee', 'fees', 'commission', 'marketplace_fee', 'transaction_fee'));
+  return {
+    date: pick('date', 'created', 'created_at', 'timestamp', 'order_date') || new Date().toISOString(),
+    marketplace,
+    title: pick('title', 'design', 'product', 'listing', 'item', 'pattern') || null,
+    design: pick('design', 'title', 'product', 'listing') || null,
+    units: n(pick('units', 'quantity', 'qty', 'count') || 1),
+    gross,
+    fee,
+    net: (pick('net') != null) ? n(pick('net')) : (gross - fee),
+    currency: pick('currency', 'cur') || 'USD',
+    orderId: pick('orderId', 'order_id', 'receipt_id', 'transaction_id', 'id') || null,
+  };
+}
+// Idempotency: a webhook may retry — skip a row we already have (same marketplace+orderId).
+function saleAlreadyRecorded(marketplace, orderId) {
+  if (!orderId) return false;
+  return readLedger().some(r => r.marketplace === marketplace && String(r.orderId) === String(orderId));
+}
+
 // ---- tiny helpers -------------------------------------------------------------
 function readJSON(file, fallback) {
   try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
@@ -215,6 +258,23 @@ const server = http.createServer(async (req, res) => {
   // health check is auth-exempt (so pm2/monitors get a clean 200)
   if (p === '/healthz') return send(res, 200, { ok: true, ts: Date.now() });
 
+  // Inbound sales webhook — token-gated (NOT basic-auth, so external senders work).
+  // Any source (Zapier/Make from a sale-email, a poller, manual curl) POSTs a sale here.
+  if (p === '/webhook/sale' && req.method === 'POST') {
+    const token = req.headers['x-webhook-token'] || url.searchParams.get('token') || '';
+    if (!WEBHOOK_TOKEN || token !== WEBHOOK_TOKEN) return send(res, 401, { ok: false, error: 'bad or missing webhook token' });
+    const row = normalizeSalePayload(await readBody(req));
+    if (!row.marketplace || !marketplaceKeys().has(row.marketplace)) {
+      return send(res, 400, { ok: false, error: `unknown marketplace "${row.marketplace}"; must be one of ${[...marketplaceKeys()].join(', ')}` });
+    }
+    if (row.gross === 0 && row.units === 0) return send(res, 400, { ok: false, error: 'need gross or units' });
+    if (saleAlreadyRecorded(row.marketplace, row.orderId)) {
+      return send(res, 200, { ok: true, duplicate: true, orderId: row.orderId, note: 'already recorded — skipped (idempotent)' });
+    }
+    fs.appendFileSync(LEDGER, JSON.stringify({ ...row, recordedAt: new Date().toISOString(), via: 'webhook' }) + '\n');
+    return send(res, 200, { ok: true, row });
+  }
+
   if (!checkAuth(req)) return unauthorized(res);
 
   if (p === '/api/data') return send(res, 200, buildSnapshot());

← 3a526d7 fix: static send() JSON-stringified Buffers → serve raw byte  ·  back to Wpb Sales Dashboard  ·  (newest)