← 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
A scripts/register-shopify-webhooks.js
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 →