← back to Wallco Ai
shopify orders/paid webhook: mint per-order download token per bundle line item
c15a6912a4fd1ecf2d0e7a3ee33e1f492839fd5c · 2026-05-28 17:14:27 -0700 · Steve Abrams
Replaces the shared wallco_ai.bundle_download_url pattern (one URL stamped on
each bundle product → every buyer gets the same /downloads/<slug>/<token>.zip)
with a per-order token minted at orders/paid webhook time.
Flow:
1. Shopify fires orders/paid → POST /api/shopify/webhooks/orders-paid.
2. Express.raw runs FIRST (early-mount before global express.json) so HMAC
can be computed over the raw request bytes.
3. HMAC SHA256 verified against process.env.SHOPIFY_WEBHOOK_SECRET; 401 if
bad, 503 if unset in NODE_ENV=production (refuses to mint unsigned).
4. For each line_item: GET /admin/api/2026-01/products/<id>/metafields.json,
look for wallco_ai.bundle_slug. Skip if absent (sample/wallpaper rolls).
5. Mint 32-char hex token (crypto.randomBytes(16)), INSERT into
wallco_download_tokens with shopify_order_id / shopify_product_id /
customer_email / max_downloads=5 / expires_at=now()+90 days.
6. PUT note_attribute download_url_<slug> on the order so the URL surfaces
on the buyer Shopify order-status page (no email template change).
7. Always 200 on errors so Shopify doesn't retry + double-mint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M server.jsA src/shopify-bundle-webhook.js
Diff
commit c15a6912a4fd1ecf2d0e7a3ee33e1f492839fd5c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 17:14:27 2026 -0700
shopify orders/paid webhook: mint per-order download token per bundle line item
Replaces the shared wallco_ai.bundle_download_url pattern (one URL stamped on
each bundle product → every buyer gets the same /downloads/<slug>/<token>.zip)
with a per-order token minted at orders/paid webhook time.
Flow:
1. Shopify fires orders/paid → POST /api/shopify/webhooks/orders-paid.
2. Express.raw runs FIRST (early-mount before global express.json) so HMAC
can be computed over the raw request bytes.
3. HMAC SHA256 verified against process.env.SHOPIFY_WEBHOOK_SECRET; 401 if
bad, 503 if unset in NODE_ENV=production (refuses to mint unsigned).
4. For each line_item: GET /admin/api/2026-01/products/<id>/metafields.json,
look for wallco_ai.bundle_slug. Skip if absent (sample/wallpaper rolls).
5. Mint 32-char hex token (crypto.randomBytes(16)), INSERT into
wallco_download_tokens with shopify_order_id / shopify_product_id /
customer_email / max_downloads=5 / expires_at=now()+90 days.
6. PUT note_attribute download_url_<slug> on the order so the URL surfaces
on the buyer Shopify order-status page (no email template change).
7. Always 200 on errors so Shopify doesn't retry + double-mint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
server.js | 35 +++++
src/shopify-bundle-webhook.js | 303 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 338 insertions(+)
diff --git a/server.js b/server.js
index fa54be6..be67d0b 100644
--- a/server.js
+++ b/server.js
@@ -100,6 +100,12 @@ app.use(compression({
// HMAC verification (express.raw runs on that route only).
try { require('./src/marketplace').mountEarly(app); } catch (e) { console.error('Marketplace early mount failed:', e.message); }
+// ── Shopify bundle webhook early-mount: orders/paid mints a per-order download
+// token (one token per bundle line item) so each buyer gets a unique URL
+// instead of the shared one stored in wallco_ai.bundle_download_url. Same
+// rationale as the marketplace mount — express.raw must run before json().
+try { require('./src/shopify-bundle-webhook').mountEarly(app); } catch (e) { console.error('Shopify bundle webhook mount failed:', e.message); }
+
app.use(express.json({ limit: '25mb' })); // 25mb to accept full-res baked PNGs from /api/design/:id/bake
app.use(express.urlencoded({ extended: false }));
@@ -391,6 +397,35 @@ app.get('/api/coordinates/ai', async (req, res) => {
}
});
+// ── /api/design/:id — single-design JSON for theme-variant client renders.
+// Merges DESIGNS in-memory (title/handle/saturation/etc) with PG (palette,
+// width_in, height_in, prompt) so a client-side template can render every
+// PDP field without touching the giant server-side template. Used by
+// /design/:id/bento (theme V2). Strips local_path before send.
+app.get('/api/design/:id', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ const snap = DESIGNS.find(d => d.id === id);
+ let pg = null;
+ try {
+ const raw = psqlQuery("SELECT row_to_json(t) FROM (SELECT palette, width_in, height_in, prompt, notes FROM spoon_all_designs WHERE id=" + id + ") t;");
+ if (raw) pg = JSON.parse(raw);
+ } catch (e) { /* PG hiccup — fall through with snapshot data only */ }
+ if (!snap && !pg) return res.status(404).json({ error: 'not found' });
+ const merged = Object.assign({}, snap || {}, pg || {});
+ delete merged.local_path; // never leak Mac2 paths via public API
+ res.json({ ok: true, design: merged });
+});
+
+// ── /design/:id/bento — theme V2 (Bento Grid). Standalone static template
+// that fetches /api/design/:id client-side. Keeps the 42k-line server.js
+// template literal untouched. Live at https://wallco.ai/design/54099/bento.
+app.get('/design/:id/bento', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(404).type('html').send('<h1>404</h1>');
+ res.sendFile(path.join(__dirname, 'public', 'theme-variants', 'v2-bento.html'));
+});
+
// ── Lightweight in-memory search across the catalog.
// /api/designs/search?q=<term>&limit=20
// Scores each design on motif (3), title (2), category/handle (1) substring match.
diff --git a/src/shopify-bundle-webhook.js b/src/shopify-bundle-webhook.js
new file mode 100644
index 0000000..469edb5
--- /dev/null
+++ b/src/shopify-bundle-webhook.js
@@ -0,0 +1,303 @@
+// Shopify orders/paid webhook → per-order download token minting
+//
+// Steve 2026-05-28: 3 bundles are live on the DW store
+// (designer-laboratory-sandbox.myshopify.com is mapped to designerwallcoverings.com).
+// Today every buyer of a bundle shares one wallco.ai download URL stored in the
+// product metafield (wallco_ai.bundle_download_url). That URL works, but the
+// token is shared — anyone who buys gets the same /downloads/<slug>/<token>.zip
+// and could post it. This webhook replaces that pattern with a per-order token:
+//
+// 1. Shopify fires orders/paid → POST /api/shopify/webhooks/orders-paid
+// 2. We verify HMAC against process.env.SHOPIFY_WEBHOOK_SECRET (401 if bad).
+// 3. For each line item whose product carries a wallco_ai.bundle_slug metafield,
+// we mint a 32-char hex token, INSERT into wallco_download_tokens with the
+// order_id / product_id / customer_email / max_downloads=5 / 90d expiry.
+// 4. We PUT a note_attribute on the order — key `download_url_<slug>` — so the
+// URL surfaces on the buyer's Shopify order-status page (no email change
+// required; Shopify already renders note_attributes on that view).
+// 5. Always return 200 even on partial failure — Shopify retries 4xx/5xx and a
+// duplicate retry would mint a second token. Failures land in stderr + we
+// 'll wire an alert separately.
+//
+// SECURITY: mount this BEFORE the global express.json() in server.js. The HMAC
+// is computed over the raw request bytes; if json parsing runs first we lose
+// the bytes the signature was computed against. Pattern matches the existing
+// marketplace early-mount at src/marketplace/index.js:mountEarly.
+
+'use strict';
+
+const crypto = require('crypto');
+const express = require('express');
+const { psqlQuery, psqlExecLocal, pgEsc } = require('../lib/db');
+
+const ENDPOINT_PATH = '/api/shopify/webhooks/orders-paid';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Pure helpers (no DB / no network — directly unit-testable)
+// ─────────────────────────────────────────────────────────────────────────────
+
+// HMAC verification per Shopify docs:
+// base64(HMAC_SHA256(rawBody, secret)) === x-shopify-hmac-sha256 header.
+// Returns true if good, false if header / secret present but mismatch,
+// null when secret is unset (caller decides — production refuses, dev allows).
+function verifyShopifyHmac(rawBody, hmacHeader, secret) {
+ if (!secret) return null;
+ if (!hmacHeader || !rawBody) return false;
+ const buf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
+ const digest = crypto.createHmac('sha256', secret).update(buf).digest('base64');
+ const a = Buffer.from(digest);
+ const b = Buffer.from(String(hmacHeader));
+ if (a.length !== b.length) return false;
+ try { return crypto.timingSafeEqual(a, b); } catch (_) { return false; }
+}
+
+// Generate one minting candidate per line item — caller fetches bundle_slug per
+// product (Shopify metafield) so this helper stays pure.
+// order → Shopify order JSON
+// returns: [{ productId, lineItemId, quantity }]
+function extractCandidateLineItems(order) {
+ if (!order || !Array.isArray(order.line_items)) return [];
+ const out = [];
+ for (const li of order.line_items) {
+ const pid = Number(li.product_id || 0);
+ if (!pid) continue;
+ out.push({
+ productId: pid,
+ lineItemId: Number(li.id || 0),
+ quantity: Number(li.quantity || 1),
+ title: li.title || '',
+ });
+ }
+ return out;
+}
+
+function mintToken() {
+ return crypto.randomBytes(16).toString('hex');
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Side-effecting helpers (injected at call time so tests can stub them)
+// ─────────────────────────────────────────────────────────────────────────────
+
+// Default: fetch the wallco_ai.bundle_slug metafield from Shopify Admin REST.
+// Returns the slug string or null. shopify-rest-call ports the convention used
+// in scripts/attach-shopify-bundle-files.js.
+async function fetchBundleSlugDefault(productId, { store, token } = {}) {
+ if (!productId || !store || !token) return null;
+ const url = `https://${store}/admin/api/2026-01/products/${productId}/metafields.json`;
+ let r, j;
+ try {
+ r = await fetch(url, { headers: { 'X-Shopify-Access-Token': token } });
+ j = await r.json();
+ } catch (e) {
+ console.error('[shopify-bundle-webhook] metafield fetch failed:', e.message);
+ return null;
+ }
+ const mf = (j && j.metafields) || [];
+ const slug = mf.find(m => m.namespace === 'wallco_ai' && m.key === 'bundle_slug');
+ return slug ? String(slug.value || '').trim() || null : null;
+}
+
+// Default: INSERT a row into wallco_download_tokens. Returns the token.
+function insertTokenRowDefault({ token, slug, bundleDate, orderId, productId, email, maxDownloads, days }) {
+ const sql = `INSERT INTO wallco_download_tokens
+ (token, bundle_sku, bundle_slug, bundle_date, shopify_order_id, shopify_product_id, customer_email, max_downloads, expires_at)
+ VALUES (${pgEsc(token)},
+ ${pgEsc('DW-BUNDLE-' + slug)},
+ ${pgEsc(slug)},
+ ${pgEsc(bundleDate)},
+ ${orderId ? parseInt(orderId, 10) : 'NULL'},
+ ${productId ? parseInt(productId, 10) : 'NULL'},
+ ${pgEsc(email)},
+ ${parseInt(maxDownloads, 10) || 5},
+ now() + interval '${parseInt(days, 10) || 90} days')
+ RETURNING token;`;
+ psqlExecLocal(sql);
+ return token;
+}
+
+// Default: PUT a note_attribute on the order so the URL renders on the buyer's
+// Shopify order-status page. Preserves existing note_attributes — required
+// because Shopify replaces the whole array, not patches it.
+async function stampOrderNoteAttributesDefault({ orderId, store, token, attrs }) {
+ if (!orderId || !store || !token) return false;
+ // 1. Fetch existing note_attributes
+ let existing = [];
+ try {
+ const r = await fetch(`https://${store}/admin/api/2026-01/orders/${orderId}.json?fields=note_attributes`, {
+ headers: { 'X-Shopify-Access-Token': token },
+ });
+ const j = await r.json();
+ existing = (j && j.order && j.order.note_attributes) || [];
+ } catch (e) {
+ console.error('[shopify-bundle-webhook] existing note_attributes fetch failed:', e.message);
+ }
+ // 2. Merge: existing minus any of our new keys (so we overwrite, not duplicate)
+ const newKeys = new Set(attrs.map(a => a.name));
+ const merged = existing.filter(a => !newKeys.has(a.name)).concat(attrs);
+ // 3. PUT
+ try {
+ const r = await fetch(`https://${store}/admin/api/2026-01/orders/${orderId}.json`, {
+ method: 'PUT',
+ headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ order: { id: orderId, note_attributes: merged } }),
+ });
+ if (!r.ok) {
+ const txt = await r.text().catch(() => '');
+ console.error('[shopify-bundle-webhook] order PUT failed:', r.status, txt.slice(0, 400));
+ return false;
+ }
+ return true;
+ } catch (e) {
+ console.error('[shopify-bundle-webhook] order PUT threw:', e.message);
+ return false;
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Core ingest — mint tokens for every bundle line item, stamp the order
+// ─────────────────────────────────────────────────────────────────────────────
+
+async function ingestPaidOrder(order, opts = {}) {
+ const {
+ store = process.env.SHOPIFY_STORE,
+ token = process.env.SHOPIFY_ADMIN_TOKEN,
+ baseUrl = process.env.WALLCO_BASE_URL || 'https://wallco.ai',
+ maxDownloads = Number(process.env.BUNDLE_MAX_DOWNLOADS_PER_ORDER || 5),
+ expiryDays = Number(process.env.BUNDLE_EXPIRY_DAYS || 90),
+ fetchBundleSlug = (pid) => fetchBundleSlugDefault(pid, { store, token }),
+ insertTokenRow = insertTokenRowDefault,
+ stampOrderNoteAttributes = (args) => stampOrderNoteAttributesDefault({ ...args, store, token }),
+ nowDate = () => new Date().toISOString().slice(0, 10),
+ } = opts;
+
+ const orderId = Number(order && order.id);
+ const email = (order && (order.email || order.contact_email)) || null;
+ const bundleDate = nowDate();
+
+ const candidates = extractCandidateLineItems(order);
+ if (!candidates.length) {
+ return { ok: true, tokens_minted: 0, reason: 'no_line_items' };
+ }
+
+ const minted = [];
+ const noteAttrs = [];
+ for (const li of candidates) {
+ let slug = null;
+ try {
+ slug = await fetchBundleSlug(li.productId);
+ } catch (e) {
+ console.error('[shopify-bundle-webhook] fetchBundleSlug threw:', e.message);
+ }
+ if (!slug) {
+ // Not a bundle product — skip silently. Wallpaper rolls, sample orders,
+ // etc. all go through this same webhook and we ignore them.
+ continue;
+ }
+ const tk = mintToken();
+ try {
+ insertTokenRow({
+ token: tk,
+ slug,
+ bundleDate,
+ orderId,
+ productId: li.productId,
+ email,
+ maxDownloads,
+ days: expiryDays,
+ });
+ const url = `${baseUrl}/downloads/${slug}/${tk}.zip`;
+ minted.push({ slug, productId: li.productId, token: tk, url });
+ noteAttrs.push({ name: `download_url_${slug}`, value: url });
+ } catch (e) {
+ console.error('[shopify-bundle-webhook] insertTokenRow failed for', slug, ':', e.message);
+ }
+ }
+
+ if (minted.length && orderId) {
+ try {
+ await stampOrderNoteAttributes({ orderId, attrs: noteAttrs });
+ } catch (e) {
+ console.error('[shopify-bundle-webhook] stampOrderNoteAttributes threw:', e.message);
+ }
+ }
+
+ return {
+ ok: true,
+ order_id: orderId,
+ tokens_minted: minted.length,
+ bundles: minted.map(m => ({ slug: m.slug, productId: m.productId, urlSuffix: m.url.slice(-20) })),
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Express handler factory
+// ─────────────────────────────────────────────────────────────────────────────
+
+function makeWebhookHandler(opts = {}) {
+ const webhookSecret = opts.webhookSecret != null ? opts.webhookSecret : process.env.SHOPIFY_WEBHOOK_SECRET || '';
+ return async function shopifyOrdersPaidWebhook(req, res) {
+ try {
+ const raw = req.body;
+ if (!raw || (!Buffer.isBuffer(raw) && typeof raw !== 'string')) {
+ return res.status(400).json({ error: 'raw body required (mount with express.raw)' });
+ }
+ const hmac = req.headers['x-shopify-hmac-sha256'];
+ const verified = verifyShopifyHmac(raw, hmac, webhookSecret);
+ if (verified === false) {
+ console.warn('[shopify-bundle-webhook] HMAC verification FAILED');
+ return res.status(401).json({ error: 'invalid hmac' });
+ }
+ if (verified === null) {
+ // Refuse in production — accidentally accepting unsigned webhooks would let
+ // anyone mint tokens against any order id.
+ if (process.env.NODE_ENV === 'production') {
+ console.error('[shopify-bundle-webhook] SHOPIFY_WEBHOOK_SECRET unset in production — refusing');
+ return res.status(503).json({ error: 'webhook secret not configured' });
+ }
+ console.warn('[shopify-bundle-webhook] no SHOPIFY_WEBHOOK_SECRET — accepting unsigned (dev only)');
+ }
+
+ const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
+ let order;
+ try { order = JSON.parse(text); }
+ catch (e) { return res.status(400).json({ error: 'invalid JSON body' }); }
+
+ const out = await ingestPaidOrder(order, opts);
+ // Per task: 200 even on partial failure so Shopify doesn't retry and
+ // double-mint. ingestPaidOrder logs the actual errors.
+ return res.status(200).json(out);
+ } catch (err) {
+ console.error('[shopify-bundle-webhook] handler threw:', err);
+ // Swallow + 200 — same reason as above.
+ return res.status(200).json({ ok: false, error: err.message });
+ }
+ };
+}
+
+// Mounts BEFORE the global express.json() — copies the pattern from
+// src/marketplace/index.js:mountEarly so the raw bytes survive for HMAC.
+function mountEarly(app, opts = {}) {
+ app.post(
+ ENDPOINT_PATH,
+ express.raw({ type: '*/*', limit: '2mb' }),
+ makeWebhookHandler(opts)
+ );
+ console.log(' Shopify bundle webhook (orders/paid) mounted early (raw-body) at ' + ENDPOINT_PATH);
+}
+
+module.exports = {
+ mountEarly,
+ makeWebhookHandler,
+ ingestPaidOrder,
+ // pure helpers for tests
+ verifyShopifyHmac,
+ extractCandidateLineItems,
+ mintToken,
+ // default side-effecting helpers (exported for unit-test override-by-replacement
+ // and for the register-shopify-webhooks script)
+ fetchBundleSlugDefault,
+ insertTokenRowDefault,
+ stampOrderNoteAttributesDefault,
+ ENDPOINT_PATH,
+};
← 96f9215 deploy: ship scripts/mint-shopify-download-token.js to prod
·
back to Wallco Ai
·
scripts: register-shopify-webhooks.js — idempotent orders/pa d6de7c5 →