[object Object]

← back to Dw Signup Fulfillment

Retail free-samples: pivot to verify→tag (Option C), drop gift cards

75d27f5e64e43f11e3f7e0370d94739145644199 · 2026-08-14 09:43:07 -0700 · Steve Abrams

- Double opt-in: /claim or customers/create webhook → branded verify letter → /verify
  appends the `verified-sample` customer tag. The store's Regios tag-gated sample
  discount (same mechanism as trade memos, scoped to the Sample variant) makes samples
  free — roll variant on the same product is never touched.
- New lib/verify.js (stateless HMAC token), retail-webhook.js rewired to send the verify
  letter, email.js verify+unlocked templates, config VERIFIED_TAG/VERIFY_SECRET/
  VERIFY_TTL_HOURS, server.js /claim + /verify routes + branded pages.
- Gift-card / ledger / mint-cap / shared-coupon paths retired to legacy alternates.
- Verified end-to-end in DRY_RUN (token round-trip, /claim, /verify → single tag write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 75d27f5e64e43f11e3f7e0370d94739145644199
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 14 09:43:07 2026 -0700

    Retail free-samples: pivot to verify→tag (Option C), drop gift cards
    
    - Double opt-in: /claim or customers/create webhook → branded verify letter → /verify
      appends the `verified-sample` customer tag. The store's Regios tag-gated sample
      discount (same mechanism as trade memos, scoped to the Sample variant) makes samples
      free — roll variant on the same product is never touched.
    - New lib/verify.js (stateless HMAC token), retail-webhook.js rewired to send the verify
      letter, email.js verify+unlocked templates, config VERIFIED_TAG/VERIFY_SECRET/
      VERIFY_TTL_HOURS, server.js /claim + /verify routes + branded pages.
    - Gift-card / ledger / mint-cap / shared-coupon paths retired to legacy alternates.
    - Verified end-to-end in DRY_RUN (token round-trip, /claim, /verify → single tag write).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/config.js         |  17 ++++++++
 lib/email.js          |  54 ++++++++++++++++++++++++-
 lib/retail-webhook.js |  72 +++++++++++++--------------------
 lib/verify.js         |  95 +++++++++++++++++++++++++++++++++++++++++++
 server.js             | 110 ++++++++++++++++++++++++++++++++++++++++++++------
 5 files changed, 291 insertions(+), 57 deletions(-)

diff --git a/lib/config.js b/lib/config.js
index 3d10f13..4a7ddc3 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -51,6 +51,23 @@ const config = {
   FREE_SAMPLE_COUNT: parseInt(process.env.FREE_SAMPLE_COUNT || '3', 10),
   CURRENCY: process.env.CURRENCY || 'USD',
 
+  // --- Retail double-opt-in verify -> tag-gated samples (Option C, DTD 2026-08-14) ---
+  // WIRED retail path (lib/verify.js): a new/claiming customer confirms their email,
+  // and the /verify click appends VERIFIED_TAG to their Shopify customer. The store's
+  // tag-gated sample discount (the SAME Regios mechanism that already makes `trade`
+  // memos free — scoped to the "Sample" variant, so the $80+ roll is never touched)
+  // then shows their samples free at checkout. No gift card, no ledger, no coupon.
+  // GO-LIVE: create the Regios rule "samples free for tag <VERIFIED_TAG>" (clone the
+  // trade-memo rule) and keep this value === that rule's tag.
+  VERIFIED_TAG: process.env.VERIFIED_TAG || 'verified-sample',
+  // Signs the stateless email-verify token. From the secrets master (256-bit random),
+  // NEVER a documented value. Empty in LIVE -> mint/read fail closed (no token issued,
+  // /verify 503) so a missing secret disables the reward rather than trusting a
+  // guessable key. In DRY_RUN a dev fallback is used so local testing round-trips.
+  VERIFY_SECRET: firstEnv('DW_SIGNUP_VERIFY_SECRET', SECRETS_ENVS),
+  // Verify-link lifetime in hours (default 7 days).
+  VERIFY_TTL_HOURS: parseInt(process.env.VERIFY_TTL_HOURS || '168', 10),
+
   // WIRED retail path (lib/retail-code.js): the SHARED discount code created once in
   // Shopify admin against the "DW Free Samples" function, limited to one-use-per-
   // customer. The service just emails this code to every new customer. Must match the
diff --git a/lib/email.js b/lib/email.js
index 1ff9ace..af32684 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -151,6 +151,58 @@ function designerWelcomeEmail({ firstName }) {
   return { subject, html };
 }
 
+// WIRED retail template (Option C, 2026-08-14): double opt-in "confirm your email to
+// unlock N free samples" letter. The button carries a signed verify token; clicking it
+// tags the customer so samples show free at checkout (no code to type, no gift card).
+function verifyEmail({ firstName, url, count }) {
+  const fn = firstName ? String(firstName).trim() : '';
+  const greet = fn ? esc(fn.charAt(0).toUpperCase() + fn.slice(1)) : 'there';
+  const subject = `Confirm your email to unlock ${count} free samples 🎁`;
+  const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto">
+  <div style="border:1px solid #e2ddd4;border-radius:10px;overflow:hidden">
+    <div style="background:#1a1a1a;color:#fff;text-align:center;padding:26px 20px">
+      <div style="font-size:22px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
+      <div style="font-size:11px;letter-spacing:2px;color:#b8afa2;margin-top:4px">FINE WALLCOVERINGS &amp; FABRICS</div>
+    </div>
+    <div style="padding:26px 28px;font-size:15px;line-height:1.7;color:#2a2a2a">
+      <p style="margin:0 0 14px">Dear ${greet},</p>
+      <p style="margin:0 0 14px">Welcome to Designer Wallcoverings — home to the world's most beautiful wallcoverings and fabrics from over 200 of the finest design houses, all in one place.</p>
+      <p style="margin:0 0 8px">You're one click from <b>${count} free samples</b>. Confirm your email and we'll switch them on:</p>
+      <div style="text-align:center;margin:22px 0 8px">
+        <a href="${esc(url)}" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:13px 34px;border-radius:30px;font-size:14px;letter-spacing:1px;display:inline-block">Unlock my ${count} free samples</a>
+      </div>
+      <p style="margin:14px 0 0;color:#555;font-size:13px">Once confirmed, your samples show free at checkout — just add your swatches, see them in your space, and fall in love before you commit. This link is just for you and expires in a few days. If you didn't create a Designer Wallcoverings account, you can ignore this email.</p>
+      <p style="margin:20px 0 0;color:#2a2a2a">Warmly,<br><b>The Designer Wallcoverings Team</b></p>
+    </div>
+  </div>
+</div>`;
+  return { subject, html };
+}
+
+// Confirmation letter sent after a successful /verify — the reward is now live.
+function samplesUnlockedEmail({ firstName, count }) {
+  const fn = firstName ? String(firstName).trim() : '';
+  const greet = fn ? esc(fn.charAt(0).toUpperCase() + fn.slice(1)) : 'there';
+  const subject = `You're all set — ${count} free samples unlocked`;
+  const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto">
+  <div style="border:1px solid #e2ddd4;border-radius:10px;overflow:hidden">
+    <div style="background:#1a1a1a;color:#fff;text-align:center;padding:26px 20px">
+      <div style="font-size:22px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
+      <div style="font-size:11px;letter-spacing:2px;color:#b8afa2;margin-top:4px">FINE WALLCOVERINGS &amp; FABRICS</div>
+    </div>
+    <div style="padding:26px 28px;font-size:15px;line-height:1.7;color:#2a2a2a">
+      <p style="margin:0 0 14px">Dear ${greet},</p>
+      <p style="margin:0 0 14px">Your email is confirmed and your <b>${count} free samples</b> are ready. Add any sample swatches to your cart — they'll show free at checkout, no code needed.</p>
+      <div style="text-align:center;margin:22px 0 6px">
+        <a href="https://designerwallcoverings.com" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:13px 34px;border-radius:30px;font-size:14px;letter-spacing:1px;display:inline-block">Explore the Collections</a>
+      </div>
+      <p style="margin:20px 0 0;color:#2a2a2a">Warmly,<br><b>The Designer Wallcoverings Team</b></p>
+    </div>
+  </div>
+</div>`;
+  return { subject, html };
+}
+
 // PRIMARY retail template: a unique function-backed sample code (NOT a gift card).
 // Copy is careful NOT to imply instant free product — it hands them a CODE to use
 // at checkout on sample swatches.
@@ -235,4 +287,4 @@ function tradeApplicationEmail({ app, approveUrl, rejectUrl, adminUrl }) {
 function money(v) { return `$${Number(v).toFixed(2)}`; }
 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
 
-module.exports = { sendEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, money, esc };
+module.exports = { sendEmail, verifyEmail, samplesUnlockedEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, money, esc };
diff --git a/lib/retail-webhook.js b/lib/retail-webhook.js
index 8cfd30a..f7f775f 100644
--- a/lib/retail-webhook.js
+++ b/lib/retail-webhook.js
@@ -1,24 +1,25 @@
 'use strict';
-// Secret-less webhook authentication (Steve's decision, TK-10006 — "verify without
-// the secret"). Instead of HMAC (which needs the Full Access app's API secret), each
-// customers/create event is authenticated by RE-FETCHING the customer from Shopify by
-// id, and everything downstream uses Shopify's REAL on-file data — never the webhook
-// payload's fields.
+// customers/create webhook handler — WIRED to the double-opt-in verify flow
+// (Option C, DTD 2026-08-14). It no longer mints a gift card; it sends the customer a
+// branded "confirm your email to unlock N free samples" letter (lib/verify.js). The
+// reward (the VERIFIED_TAG that makes samples free at checkout) is applied only when
+// the customer CLICKS the verify link — so this endpoint hands out no value, just an
+// email, and the double opt-in also keeps junk/bot signups off the reward.
 //
-// Why this is safe against a forged/replayed POST to the public endpoint:
-//   1) AUTH — a forged/unknown customer id is not found in Shopify → rejected.
-//   2) NO REDIRECT — the gift card is created for the real customer id and emailed to
-//      the customer's REAL on-file email (from the re-fetch), so a forged event can
-//      only ever deliver the code to the legitimate customer's own account. An
-//      attacker gains nothing (they can't point it at themselves).
-//   3) IDEMPOTENCY — a custom.welcome_gift_issued metafield guarantees exactly ONE
-//      gift card per customer, ever. Replays / duplicate deliveries are no-ops.
+// Anti-forgery (unchanged intent): a forged/replayed POST to the public endpoint is
+// authenticated by RE-FETCHING the customer from Shopify by id and using the REAL
+// on-file email — a forged event can only ever mail a legitimate customer's own inbox.
+//   1) AUTH      — unknown/forged id is not found in Shopify -> rejected.
+//   2) FRESHNESS — refuse customers older than WEBHOOK_FRESHNESS_MIN (a real
+//                  customers/create fires within seconds) so the endpoint can't be
+//                  driven to spam the existing base.
+//   3) IDEMPOTENCY — a custom.sample_verify_sent metafield sends the verify letter at
+//                  most once per customer; replays/duplicates are no-ops.
 const config = require('./config');
 const shopify = require('./shopify');
-const giftcard = require('./giftcard');
-const mintLedger = require('./mint-ledger');
+const verify = require('./verify');
 
-const GIFT_FLAG = { namespace: 'custom', key: 'welcome_gift_issued' };
+const SENT_FLAG = { namespace: 'custom', key: 'sample_verify_sent' };
 
 async function handleCustomerCreate(payload) {
   const id = payload && payload.id;
@@ -31,43 +32,28 @@ async function handleCustomerCreate(payload) {
     return { ok: false, reason: 'customer_not_found_or_no_email', id };
   }
 
-  // 2) FRESHNESS — a real customers/create fires within seconds of signup. Refuse to
-  //    gift a customer whose Shopify created_at is older than WEBHOOK_FRESHNESS_MIN, so
-  //    the public endpoint can't be driven to mint gift cards to the EXISTING customer
-  //    base. (Missing created_at ⇒ not real Shopify data ⇒ treated as stale/rejected.)
+  // 2) FRESHNESS — only a freshly-created customer gets the letter. (Missing created_at
+  //    ⇒ not real Shopify data ⇒ treated as stale/rejected.)
   const createdMs = real.created_at ? Date.parse(real.created_at) : NaN;
   const ageMin = Number.isNaN(createdMs) ? Infinity : (Date.now() - createdMs) / 60000;
   if (ageMin > config.WEBHOOK_FRESHNESS_MIN) {
     return { ok: false, reason: 'stale_customer', id, ageMin: Number.isFinite(ageMin) ? Math.round(ageMin) : null };
   }
 
-  // 3) IDEMPOTENCY — skip if this customer already received their welcome gift.
-  const flag = await shopify.getCustomerMetafield(id, GIFT_FLAG.namespace, GIFT_FLAG.key);
+  // 3) IDEMPOTENCY — send the verify letter at most once per customer.
+  const flag = await shopify.getCustomerMetafield(id, SENT_FLAG.namespace, SENT_FLAG.key);
   if (flag && String(flag).toLowerCase() === 'true') {
-    return { ok: true, skipped: 'already_issued', id };
+    return { ok: true, skipped: 'already_sent', id };
   }
 
-  // 4) DAILY MINT CAP — money backstop. Beyond the cap, skip + warn (never mint unbounded).
-  if (mintLedger.todayCount() >= config.MINT_DAILY_CAP) {
-    console.warn(`[retail-webhook] daily mint cap ${config.MINT_DAILY_CAP} reached — skipping id=${id}`);
-    return { ok: false, skipped: 'daily_cap_reached', id, cap: config.MINT_DAILY_CAP };
-  }
-
-  // 5) ISSUE — gift card for the REAL customer (real id + real email; payload ignored).
-  const issued = await giftcard.issueRetailGiftCode({ id: real.id, email: real.email, first_name: real.first_name });
+  // 4) SEND — the branded "confirm your email" letter to the REAL on-file email, with
+  //    the real customer id baked into the token so the tag lands on the right account.
+  const started = await verify.startVerification({ email: real.email, customerId: real.id, firstName: real.first_name });
 
-  // 6) FLAG in Shopify so it never double-issues (survives restarts; "in Shopify").
-  await shopify.setCustomerMetafield(id, { ...GIFT_FLAG, value: 'true', type: 'boolean' });
-
-  // 7) LEDGER — record the mint value + surface running daily $ total (Steve's cost rule).
-  //    Only count real (non-dry-run) mints so DRY_RUN dev/selftest never inflates the cap.
-  let ledger = null;
-  if (!config.DRY_RUN && !(issued.giftCard && issued.giftCard.dryRun)) {
-    ledger = mintLedger.recordMint(issued.value != null ? issued.value : config.SAMPLE_GIFT_VALUE);
-    console.log(`[retail-webhook] $${issued.value} gift minted — today ${ledger.count}/${config.MINT_DAILY_CAP} ($${ledger.total} liability)`);
-  }
+  // 5) FLAG in Shopify so it never double-sends (survives restarts).
+  await shopify.setCustomerMetafield(id, { ...SENT_FLAG, value: 'true', type: 'boolean' });
 
-  return { ok: true, id, email: real.email, issued, ledger };
+  return { ok: started.ok, id, email: real.email, started };
 }
 
-module.exports = { handleCustomerCreate, GIFT_FLAG };
+module.exports = { handleCustomerCreate, SENT_FLAG };
diff --git a/lib/verify.js b/lib/verify.js
new file mode 100644
index 0000000..95ec96f
--- /dev/null
+++ b/lib/verify.js
@@ -0,0 +1,95 @@
+'use strict';
+// Retail free-samples via DOUBLE OPT-IN + tag-gated pricing (Option C — DTD verdict,
+// 2026-08-14). This REPLACES the gift-card path (giftcard.js / mint-ledger.js) and the
+// shared-coupon path (retail-code.js), which are kept only as reference alternates.
+//
+// A new/claiming customer is NOT given stored value. Instead:
+//   1) startVerification() emails a branded "confirm your email to unlock N free
+//      samples" link carrying a SIGNED, STATELESS token (HMAC — no DB, no nonce store).
+//   2) On click, /verify -> completeVerification() appends VERIFIED_TAG to the Shopify
+//      customer. The store's tag-gated sample discount (the SAME Regios mechanism that
+//      already makes `trade` memos free — scoped to the "Sample" variant, so the $80+
+//      roll variant is never touched) then shows their samples free at checkout.
+//
+// Why this beats the old design: no gift-card liability, no ledger, no daily mint cap,
+// no shared coupon that leaks to coupon sites, no Shopify Function / Plus dependency. A
+// customer tag is durable and non-redeemable by a stranger. Re-applying the tag is a
+// no-op, so the whole flow is idempotent and safe to replay.
+//
+// DRY_RUN-safe end to end: shopify.* writes and email.sendEmail are short-circuited by
+// their own modules when config.DRY_RUN is true.
+const crypto = require('crypto');
+const config = require('./config');
+const shopify = require('./shopify');
+const email = require('./email');
+
+// Dev-only fallback secret so tokens round-trip in DRY_RUN without provisioning one.
+// In LIVE a missing VERIFY_SECRET fails CLOSED (mintToken/readToken return no_secret,
+// /verify 503s) — a missing key disables the reward rather than trusting a guessable one.
+const DEV_SECRET = 'dw-signup-verify-dev-secret-DRYRUN-ONLY';
+function secret() { return config.VERIFY_SECRET || (config.DRY_RUN ? DEV_SECRET : ''); }
+
+function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
+function fromB64url(s) { return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
+function sign(payloadB64, sec) { return b64url(crypto.createHmac('sha256', sec).update(payloadB64).digest()); }
+
+// mintToken({email, customerId}) -> "payload.sig" (base64url), or null with no secret (live).
+function mintToken({ email: addr, customerId }) {
+  const sec = secret();
+  if (!sec) return null;
+  const payload = {
+    e: String(addr || '').trim().toLowerCase(),
+    c: customerId ? String(customerId) : '',
+    x: Date.now() + config.VERIFY_TTL_HOURS * 3600 * 1000,
+  };
+  const pB64 = b64url(JSON.stringify(payload));
+  return pB64 + '.' + sign(pB64, sec);
+}
+
+// readToken(token) -> { ok:true, email, customerId } | { ok:false, reason }
+function readToken(token) {
+  const sec = secret();
+  if (!sec) return { ok: false, reason: 'no_secret' };
+  if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return { ok: false, reason: 'malformed' };
+  const [pB64, sig] = token.split('.');
+  const expect = sign(pB64, sec);
+  const a = Buffer.from(String(sig || '')), b = Buffer.from(expect);
+  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'bad_signature' };
+  let payload;
+  try { payload = JSON.parse(fromB64url(pB64).toString('utf8')); } catch { return { ok: false, reason: 'bad_payload' }; }
+  if (!payload || typeof payload.x !== 'number' || Date.now() > payload.x) return { ok: false, reason: 'expired' };
+  if (!payload.e) return { ok: false, reason: 'no_email' };
+  return { ok: true, email: payload.e, customerId: payload.c || null };
+}
+
+function baseUrl() { return config.PUBLIC_URL || `http://127.0.0.1:${config.PORT}`; }
+
+// Step 1 — email the branded "confirm your email" letter with the verify link.
+async function startVerification({ email: to, customerId, firstName }) {
+  const addr = String(to || '').trim().toLowerCase();
+  if (!addr) return { ok: false, reason: 'no_email' };
+  const token = mintToken({ email: addr, customerId });
+  if (!token) {
+    console.warn('[verify] VERIFY_SECRET unset (live) — cannot mint verify token; skipping send.');
+    return { ok: false, reason: 'no_secret' };
+  }
+  const url = `${baseUrl()}/verify?token=${encodeURIComponent(token)}`;
+  const first = firstName || (addr.includes('@') ? addr.split('@')[0] : '');
+  const tpl = email.verifyEmail({ firstName: first, url, count: config.FREE_SAMPLE_COUNT });
+  const mail = await email.sendEmail({ to: addr, subject: tpl.subject, html: tpl.html, source: 'retail-verify' });
+  // verifyUrl carries the bearer token — callers must redact it before logging.
+  return { ok: mail.ok !== false, sent: { to: addr, subject: tpl.subject, dryRun: mail.dryRun || false }, verifyUrl: url };
+}
+
+// Step 2 — apply the tag-gated sample entitlement after a valid click. Idempotent.
+async function completeVerification({ email: addr, customerId }) {
+  let custId = customerId || null;
+  if (!custId) custId = await shopify.findCustomerByEmail(addr);
+  if (!custId) return { ok: false, reason: 'customer_not_found', email: addr };
+  const tagRes = await shopify.addTags(custId, [config.VERIFIED_TAG]);
+  // Best-effort audit flag; never fatal (the tag is what actually gates the discount).
+  try { await shopify.setCustomerMetafield(custId, { namespace: 'custom', key: 'sample_verified', value: 'true', type: 'boolean' }); } catch (e) { /* non-fatal */ }
+  return { ok: tagRes.ok !== false, customerId: custId, tag: config.VERIFIED_TAG, dryRun: tagRes.dryRun || false };
+}
+
+module.exports = { mintToken, readToken, startVerification, completeVerification, baseUrl };
diff --git a/server.js b/server.js
index 391f7b8..051185d 100644
--- a/server.js
+++ b/server.js
@@ -10,10 +10,12 @@
 // no real emails, no webhook registration until Steve flips DRY_RUN=0 at go-live.
 const express = require('express');
 const config = require('./lib/config');
-const retailWebhook = require('./lib/retail-webhook'); // WIRED: secret-less re-fetch auth + idempotent gift card
-const giftcard = require('./lib/giftcard');            // WIRED default (FINAL — Steve, memo §2): unique gift-card code per signup
-const retailCode = require('./lib/retail-code');       // alternate (shared function code — needs a DW Free Samples admin discount)
-const giftcodeDiscount = require('./lib/giftcode-discount'); // alternate (not wired)
+const retailWebhook = require('./lib/retail-webhook'); // WIRED: re-fetch auth + idempotent verify-letter send
+const verify = require('./lib/verify');                // WIRED default (Option C, DTD 2026-08-14): double opt-in -> VERIFIED_TAG -> tag-gated free samples
+const shopify = require('./lib/shopify');              // used by /claim to resolve a customer id from email
+const giftcard = require('./lib/giftcard');            // legacy alternate — stored-value gift card (retired path)
+const retailCode = require('./lib/retail-code');       // legacy alternate — shared function code
+const giftcodeDiscount = require('./lib/giftcode-discount'); // legacy alternate — collection-scoped code (unsafe: samples share a product with the roll)
 const trade = require('./lib/trade');
 const reps = require('./lib/reps');
 const email = require('./lib/email');
@@ -32,10 +34,12 @@ app.get('/', (_req, res) => {
   <style>body{font:15px/1.6 -apple-system,system-ui,sans-serif;margin:40px;color:#1a1a1a;background:#faf9f7}code{background:#eee;padding:1px 5px;border-radius:4px}.p{display:inline-block;padding:2px 8px;border-radius:4px;background:${config.DRY_RUN ? '#fde68a' : '#bbf7d0'};font-size:12px}</style>
   </head><body>
   <h1>DW Signup Fulfillment <span class="p">DRY_RUN: ${config.DRY_RUN ? 'ON' : 'OFF (LIVE)'}</span></h1>
-  <p>Service is running. It emails new customers the sample-locked code for 3 free samples and handles trade applications.</p>
+  <p>Service is running. New customers confirm their email (double opt-in) to unlock 3 free samples — the verify click tags them so samples show free at checkout. Also handles trade applications.</p>
   <ul>
     <li><code>GET /healthz</code> — liveness (open)</li>
-    <li><code>POST /webhooks/customers/create/&lt;token&gt;</code> — Shopify webhook (URL-token auth + rate-limit)</li>
+    <li><code>POST /webhooks/customers/create/&lt;token&gt;</code> — Shopify webhook → sends the verify letter (URL-token auth + rate-limit)</li>
+    <li><code>POST /claim</code> — retail sample claim (email in → verify letter out)</li>
+    <li><code>GET /verify?token=…</code> — confirm email → apply the free-samples tag</li>
     <li><code>POST /trade/apply</code> — trade application intake</li>
     <li><code>GET /admin/trade</code> — trade review (basic-auth)</li>
   </ul>
@@ -80,9 +84,10 @@ async function webhookHandler(req, res) {
   try {
     console.log(`[webhook] customers/create id=${customer.id}`);
     const result = await retailWebhook.handleCustomerCreate(customer);
-    // Redact the live gift-card code before logging — a leaked code can be spent.
-    const safe = result && result.issued && result.issued.giftCard
-      ? { ...result, issued: { ...result.issued, giftCard: { ...result.issued.giftCard, code: '***' } } }
+    // Redact the verify URL before logging — it carries a bearer token that would let a
+    // log reader self-apply the sample tag.
+    const safe = result && result.started && result.started.verifyUrl
+      ? { ...result, started: { ...result.started, verifyUrl: '***' } }
       : result;
     console.log('[webhook] retail result:', JSON.stringify(safe));
   } catch (e) {
@@ -133,6 +138,46 @@ app.post('/trade/apply', tradeCors, (req, res) => {
   res.json({ ok: true, id: created.id, status: created.status, created_at: created.created_at });
 });
 
+// --- Retail sample claim (public, double opt-in) — email in, verify letter out. ---
+//     CORS-shared with /trade/apply so the storefront can POST it from its own origin.
+app.options('/claim', tradeCors);
+app.post('/claim', tradeCors, async (req, res) => {
+  const emailAddr = String((req.body || {}).email || '').trim().toLowerCase();
+  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailAddr)) return res.status(400).json({ ok: false, error: 'valid email required' });
+  // Best-effort: resolve an existing customer id so the tag lands on the right account.
+  let custId = null;
+  try { custId = await shopify.findCustomerByEmail(emailAddr); } catch (e) { /* best-effort only */ }
+  const started = await verify.startVerification({ email: emailAddr, customerId: custId, firstName: (req.body || {}).first_name });
+  // Never reveal whether the email exists — always answer the same.
+  res.json({ ok: started.ok !== false, message: 'Check your inbox to confirm and unlock your free samples.' });
+});
+
+// Tiny branded claim form (handy for testing / embedding on a landing page).
+app.get('/claim', (_req, res) => res.type('html').send(claimPage()));
+
+// --- Verify click — validate the signed token, apply the free-samples tag. Idempotent. ---
+app.get('/verify', async (req, res) => {
+  const parsed = verify.readToken(req.query.token);
+  if (!parsed.ok) {
+    const msg = parsed.reason === 'expired'
+      ? "This confirmation link has expired. Request a new one and we'll send a fresh link."
+      : 'This confirmation link is invalid.';
+    return res.status(parsed.reason === 'no_secret' ? 503 : 400).type('html').send(verifyPage(msg, false));
+  }
+  const done = await verify.completeVerification({ email: parsed.email, customerId: parsed.customerId });
+  if (!done.ok) {
+    return res.status(200).type('html').send(verifyPage("We couldn't attach the samples to your account. Please make sure you're signed in with this email and try again — or reply to our email and we'll sort it out.", false));
+  }
+  // Confirmation letter (fire-and-forget, DRY_RUN-safe).
+  (async () => {
+    const t = email.samplesUnlockedEmail({ firstName: parsed.email.split('@')[0], count: config.FREE_SAMPLE_COUNT });
+    const r = await email.sendEmail({ to: parsed.email, subject: t.subject, html: t.html, source: 'retail-verified' });
+    if (r && r.ok === false) console.error(`[verify] unlocked-email send FAILED for ${parsed.email}: ${r.error || r.status}`);
+  })().catch(e => console.error('[verify] unlocked-email error:', e.message));
+  console.log(`[verify] tagged customer ${done.customerId} '${done.tag}'${done.dryRun ? ' (DRY_RUN)' : ''}`);
+  res.type('html').send(verifyPage(`Your ${config.FREE_SAMPLE_COUNT} free samples are unlocked. They'll show free at checkout — just add your swatches.`, true));
+});
+
 // Public base the emailed Approve/Reject buttons point at (Kamatera host at go-live).
 function baseUrl() { return config.PUBLIC_URL || `http://127.0.0.1:${config.PORT}`; }
 
@@ -173,6 +218,43 @@ function resultPage(msg, ok) {
     `<p style="color:#6b7280"><a href="/admin/trade">Open the trade admin panel</a></p></div></body>`;
 }
 
+// Branded retail claim form (email input) that POSTs to /claim.
+function claimPage() {
+  return `<!doctype html><meta charset="utf-8"><title>DW — 3 Free Samples</title>
+  <body style="font:16px/1.6 -apple-system,system-ui,sans-serif;background:#faf9f7;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
+  <div style="max-width:420px;width:100%;box-sizing:border-box;padding:32px;border:1px solid #e5e2dd;border-radius:12px;background:#fff;text-align:center">
+    <div style="font-size:16px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
+    <h1 style="font-weight:600;font-size:22px;margin:14px 0 6px">3 free samples, on us</h1>
+    <p style="color:#6b7280;margin:0 0 18px">Enter your email and we'll send a link to unlock them.</p>
+    <input id="e" type="email" placeholder="you@example.com" style="width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d6d1c8;border-radius:8px;font-size:15px">
+    <button onclick="go()" style="margin-top:12px;width:100%;padding:12px;border:0;background:#1a1a1a;color:#fff;border-radius:30px;font-size:15px;cursor:pointer">Send my link</button>
+    <p id="m" style="margin:14px 0 0;min-height:20px"></p>
+  </div>
+  <script>
+    async function go(){
+      const email=document.getElementById('e').value.trim();
+      const m=document.getElementById('m');
+      m.style.color='#6b7280'; m.textContent='Sending…';
+      try{ const r=await fetch('/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email})});
+        const j=await r.json(); m.style.color=j.ok?'#16a34a':'#b91c1c'; m.textContent=j.ok?j.message:(j.error||'Something went wrong'); }
+      catch(e){ m.style.color='#b91c1c'; m.textContent='Network error — try again.'; }
+    }
+  </script></body>`;
+}
+
+// Branded verify result page (success or failure).
+function verifyPage(msg, ok) {
+  return `<!doctype html><meta charset="utf-8"><title>Designer Wallcoverings</title>` +
+    `<body style="font:16px/1.6 -apple-system,system-ui,sans-serif;background:#faf9f7;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">` +
+    `<div style="max-width:440px;text-align:center;padding:34px;border:1px solid #e5e2dd;border-radius:12px;background:#fff">` +
+    `<div style="font-size:16px;letter-spacing:3px;font-weight:600;color:#1a1a1a">DESIGNER WALLCOVERINGS</div>` +
+    `<div style="font-size:44px;margin:14px 0 6px">${ok ? '🎁' : '⚠️'}</div>` +
+    `<h1 style="font-weight:600;font-size:20px;margin:6px 0 10px">${ok ? "You're all set" : 'Hmm.'}</h1>` +
+    `<p style="color:#4b5563;margin:0 0 18px">${esc(msg)}</p>` +
+    `<a href="https://designerwallcoverings.com" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:12px 30px;border-radius:30px;font-size:14px;display:inline-block">Explore the Collections</a>` +
+    `</div></body>`;
+}
+
 // --- Basic-auth guard for /admin/* ---
 function adminAuth(req, res, next) {
   const hdr = req.get('Authorization') || '';
@@ -234,11 +316,12 @@ app.get('/reps/next', adminAuth, (_req, res) => res.json({ assigned: reps.houseA
 app.post('/admin/retail/issue', adminAuth, async (req, res) => {
   const customer = req.body || {};
   if (!customer.email) return res.status(400).json({ ok: false, error: 'email required' });
-  let mode = req.query.mode || 'giftcard';
+  let mode = req.query.mode || 'verify';
   let result;
-  if (mode === 'sharedcode') result = await retailCode.issueRetailCode(customer);            // alternate (needs admin discount)
-  else if (mode === 'discount') result = await giftcodeDiscount.issueRetailDiscountCode(customer); // alternate
-  else { mode = 'giftcard'; result = await giftcard.issueRetailGiftCode(customer); }          // WIRED default
+  if (mode === 'giftcard') result = await giftcard.issueRetailGiftCode(customer);             // legacy alternate
+  else if (mode === 'sharedcode') result = await retailCode.issueRetailCode(customer);        // legacy alternate
+  else if (mode === 'discount') result = await giftcodeDiscount.issueRetailDiscountCode(customer); // legacy alternate
+  else { mode = 'verify'; result = await verify.startVerification(customer); }                // WIRED default (Option C)
   res.json({ ok: true, mode, result });
 });
 
@@ -297,6 +380,7 @@ if (require.main === module) {
     console.log(`  health:  http://127.0.0.1:${config.PORT}/healthz`);
     console.log(`  admin:   http://127.0.0.1:${config.PORT}/admin/trade  (basic-auth user=${config.ADMIN_USER}, pass in env/config — not logged)`);
     console.log(`  webhook: POST /webhooks/customers/create/<token>  (URL-token auth + rate-limit; token ${config.WEBHOOK_URL_TOKEN ? 'SET' : 'UNSET → 503 when live'})`);
+    console.log(`  claim:   POST /claim  ·  verify: GET /verify?token=…  (tag=${config.VERIFIED_TAG}; secret ${config.VERIFY_SECRET ? 'SET' : (config.DRY_RUN ? 'dev-fallback (DRY_RUN)' : 'UNSET → /verify 503')})`);
     if (config.DRY_RUN) console.log('  ** DRY_RUN ON — no live Shopify writes, no real emails, nothing registered. **');
   });
 }

← 1755b63 chore: session-close quality gate — comment accuracy fix + v  ·  back to Dw Signup Fulfillment  ·  DEPLOY.md: rewrite runbook for Option C (verify→tag→Regios), 9196a62 →