[object Object]

← back to Wallco Ai

scripts: register-shopify-webhooks.js — idempotent orders/paid webhook registration

d6de7c598a34d89d105419f7f2d086e050463ba2 · 2026-05-28 17:15:15 -0700 · Steve Abrams

GETs /admin/api/2026-01/webhooks.json first, skips POST if (topic, address)
pair already exists. Otherwise creates a new orders/paid webhook pointed at
https://wallco.ai/api/shopify/webhooks/orders-paid.

Flags:
  --address <url>   override webhook target (default: prod wallco.ai endpoint)
  --topic <topic>   override topic (default: orders/paid)
  --list            list all registered webhooks
  --dry             show what would be POSTed without sending

REST-created webhooks share the shop-wide signing secret (Shopify admin →
Settings → Notifications → Webhooks); paste into .env as
SHOPIFY_WEBHOOK_SECRET, then route via the secrets-manager skill.

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

Files touched

Diff

commit d6de7c598a34d89d105419f7f2d086e050463ba2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 17:15:15 2026 -0700

    scripts: register-shopify-webhooks.js — idempotent orders/paid webhook registration
    
    GETs /admin/api/2026-01/webhooks.json first, skips POST if (topic, address)
    pair already exists. Otherwise creates a new orders/paid webhook pointed at
    https://wallco.ai/api/shopify/webhooks/orders-paid.
    
    Flags:
      --address <url>   override webhook target (default: prod wallco.ai endpoint)
      --topic <topic>   override topic (default: orders/paid)
      --list            list all registered webhooks
      --dry             show what would be POSTed without sending
    
    REST-created webhooks share the shop-wide signing secret (Shopify admin →
    Settings → Notifications → Webhooks); paste into .env as
    SHOPIFY_WEBHOOK_SECRET, then route via the secrets-manager skill.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 scripts/register-shopify-webhooks.js | 144 +++++++++++++++++++++++++++++++++++
 1 file changed, 144 insertions(+)

diff --git a/scripts/register-shopify-webhooks.js b/scripts/register-shopify-webhooks.js
new file mode 100755
index 0000000..4d7f131
--- /dev/null
+++ b/scripts/register-shopify-webhooks.js
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+/**
+ * register-shopify-webhooks — register (or verify) the orders/paid webhook on
+ * the DW Shopify store so wallco.ai mints a per-order download token for each
+ * bundle line item.
+ *
+ * Idempotent: lists existing webhooks first, skips registration if the same
+ * topic + address pair already exists, otherwise creates a new one.
+ *
+ * Usage:
+ *   node scripts/register-shopify-webhooks.js
+ *   node scripts/register-shopify-webhooks.js --address https://wallco.ai/api/shopify/webhooks/orders-paid
+ *   node scripts/register-shopify-webhooks.js --topic orders/paid --dry
+ *   node scripts/register-shopify-webhooks.js --list      # show all existing webhooks
+ *
+ * After Shopify returns a webhook with a secret, paste it into your .env as
+ * SHOPIFY_WEBHOOK_SECRET (Steve's secrets-manager skill will route + fan-out).
+ *
+ * Required env:
+ *   SHOPIFY_ADMIN_TOKEN   shpat_… admin API token
+ *   SHOPIFY_STORE         the shop hostname (designer-laboratory-sandbox.myshopify.com)
+ */
+'use strict';
+
+const path = require('path');
+const ROOT = path.resolve(__dirname, '..');
+try { require('dotenv').config({ path: path.join(ROOT, '.env') }); } catch {}
+
+function argv(name, dflt) {
+  const i = process.argv.indexOf('--' + name);
+  if (i === -1) return dflt;
+  if (i === process.argv.length - 1 || process.argv[i + 1].startsWith('--')) return true;
+  return process.argv[i + 1];
+}
+
+const store   = argv('store',   process.env.SHOPIFY_STORE);
+const token   = process.env.SHOPIFY_ADMIN_TOKEN;
+const topic   = argv('topic',   'orders/paid');
+const address = argv('address', 'https://wallco.ai/api/shopify/webhooks/orders-paid');
+const format  = argv('format',  'json');
+const dry     = !!argv('dry',   false);
+const listOnly = !!argv('list', false);
+
+if (!token || !store) {
+  console.error('missing SHOPIFY_ADMIN_TOKEN or SHOPIFY_STORE in env');
+  process.exit(2);
+}
+
+const API_VERSION = '2026-01';
+const BASE = `https://${store}/admin/api/${API_VERSION}`;
+
+async function shopifyFetch(url, opts = {}) {
+  const r = await fetch(url, {
+    ...opts,
+    headers: {
+      'X-Shopify-Access-Token': token,
+      'Content-Type': 'application/json',
+      ...(opts.headers || {}),
+    },
+  });
+  const j = await r.json().catch(() => ({}));
+  return { ok: r.ok, status: r.status, body: j };
+}
+
+async function listWebhooks() {
+  const { ok, status, body } = await shopifyFetch(`${BASE}/webhooks.json?limit=250`);
+  if (!ok) {
+    console.error('list failed:', status, JSON.stringify(body).slice(0, 400));
+    process.exit(3);
+  }
+  return body.webhooks || [];
+}
+
+async function createWebhook() {
+  const payload = { webhook: { topic, address, format } };
+  if (dry) {
+    console.log('--- DRY: would POST', `${BASE}/webhooks.json`);
+    console.log(JSON.stringify(payload, null, 2));
+    return null;
+  }
+  const { ok, status, body } = await shopifyFetch(`${BASE}/webhooks.json`, {
+    method: 'POST',
+    body: JSON.stringify(payload),
+  });
+  if (!ok || !body.webhook) {
+    console.error('create failed:', status, JSON.stringify(body).slice(0, 600));
+    process.exit(4);
+  }
+  return body.webhook;
+}
+
+(async () => {
+  console.log(`store: ${store}`);
+  console.log(`api:   ${API_VERSION}`);
+  console.log();
+
+  const existing = await listWebhooks();
+
+  if (listOnly) {
+    console.log(`${existing.length} webhook(s) registered:`);
+    for (const w of existing) {
+      console.log(`  [${w.id}] ${w.topic} → ${w.address} (format=${w.format}, created ${w.created_at})`);
+    }
+    return;
+  }
+
+  const match = existing.find(w => w.topic === topic && w.address === address);
+  if (match) {
+    console.log(`✓ webhook already registered`);
+    console.log(`  id:      ${match.id}`);
+    console.log(`  topic:   ${match.topic}`);
+    console.log(`  address: ${match.address}`);
+    console.log(`  format:  ${match.format}`);
+    console.log(`  created: ${match.created_at}`);
+    console.log();
+    console.log('NOTE: Shopify only reveals the webhook secret in the *create* response. If you');
+    console.log('don\'t have SHOPIFY_WEBHOOK_SECRET stored yet, delete this webhook in Shopify');
+    console.log('admin and re-run this script — the new POST will print the secret.');
+    return;
+  }
+
+  console.log(`registering webhook → ${topic} → ${address}`);
+  const created = await createWebhook();
+  if (!created) return; // dry run
+
+  console.log();
+  console.log(`✓ webhook registered`);
+  console.log(`  id:      ${created.id}`);
+  console.log(`  topic:   ${created.topic}`);
+  console.log(`  address: ${created.address}`);
+  console.log(`  format:  ${created.format}`);
+  console.log();
+  // Shopify-managed webhooks (created via REST) inherit the shop's webhook
+  // signing secret which lives in the shop admin under Settings → Notifications.
+  // The REST response does NOT include the secret on a per-webhook basis —
+  // there's ONE shop-wide secret for REST-created webhooks. Surface that note.
+  console.log('Shopify webhook signing:');
+  console.log('  REST-created webhooks are signed with the shop-wide secret found at');
+  console.log('  Shopify admin → Settings → Notifications → "Webhooks" (bottom of page).');
+  console.log('  Copy that value into SHOPIFY_WEBHOOK_SECRET in .env, then route via:');
+  console.log('    node ~/Projects/secrets-manager/cli.js add SHOPIFY_WEBHOOK_SECRET');
+  console.log();
+  console.log('Test locally with node tests/shopify-bundle-webhook.test.js before deploy.');
+})().catch(err => { console.error('FATAL:', err.message); process.exit(99); });

← c15a691 shopify orders/paid webhook: mint per-order download token p  ·  back to Wallco Ai  ·  tests: 19 unit tests for shopify-bundle-webhook (HMAC verify 2229fb4 →