zola-mcp 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "Zola wedding planning tools for Claude Code",
10
- "version": "1.4.4"
10
+ "version": "1.5.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Zola",
16
16
  "source": "./",
17
17
  "description": "Zola wedding planning tools for Claude — vendors, budget, guests, seating, events, registry, inquiries, and more via MCP",
18
- "version": "1.4.4",
18
+ "version": "1.5.0",
19
19
  "author": {
20
20
  "name": "Chris Chall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zola",
3
3
  "displayName": "Zola",
4
- "version": "1.4.4",
4
+ "version": "1.5.0",
5
5
  "description": "Zola wedding planning tools for Claude — vendors, budget, guests, seating, events, registry, inquiries, and more via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall",
package/dist/bundle.js CHANGED
@@ -34960,6 +34960,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34960
34960
  "capture_request_header",
34961
34961
  "capture_redirect",
34962
34962
  "read_indexed_db",
34963
+ "read_dom",
34963
34964
  "download"
34964
34965
  ]);
34965
34966
 
@@ -35261,6 +35262,44 @@ function assertIndexedDbScopesArray(value, label) {
35261
35262
  }
35262
35263
  }
35263
35264
  }
35265
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35266
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35267
+ function assertDomSelectorsArray(value, label) {
35268
+ if (!Array.isArray(value)) {
35269
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35270
+ }
35271
+ const seen = /* @__PURE__ */ new Set();
35272
+ for (let i = 0; i < value.length; i++) {
35273
+ const entry = value[i];
35274
+ assertObject(entry, `${label}[${i}]`);
35275
+ if (entry.name === void 0) {
35276
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35277
+ }
35278
+ if (entry.selector === void 0) {
35279
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35280
+ }
35281
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35282
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35283
+ }
35284
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35285
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35286
+ }
35287
+ if (entry.attribute !== void 0) {
35288
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35289
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35290
+ }
35291
+ }
35292
+ if (seen.has(entry.name)) {
35293
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35294
+ }
35295
+ seen.add(entry.name);
35296
+ for (const k of Object.keys(entry)) {
35297
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35298
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35299
+ }
35300
+ }
35301
+ }
35302
+ }
35264
35303
  function validateFrame(raw) {
35265
35304
  assertObject(raw, "frame");
35266
35305
  const t = raw.type;
@@ -35336,6 +35375,9 @@ function validateHello(raw) {
35336
35375
  if (raw.sessionStoragePointers !== void 0) {
35337
35376
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35338
35377
  }
35378
+ if (raw.domSelectors !== void 0) {
35379
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35380
+ }
35339
35381
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35340
35382
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35341
35383
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35570,6 +35612,21 @@ function validateInnerRequest(raw) {
35570
35612
  }
35571
35613
  return raw;
35572
35614
  }
35615
+ if (raw.op === "read_dom") {
35616
+ assertObject(raw.init, "inner.init");
35617
+ if (raw.init.origin === void 0)
35618
+ throw new ProtocolError("inner.init.origin: missing");
35619
+ if (raw.init.names === void 0)
35620
+ throw new ProtocolError("inner.init.names: missing");
35621
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35622
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35623
+ for (const k of Object.keys(raw.init)) {
35624
+ if (k !== "origin" && k !== "names") {
35625
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35626
+ }
35627
+ }
35628
+ return raw;
35629
+ }
35573
35630
  if (raw.op === "download") {
35574
35631
  assertObject(raw.init, "inner.init");
35575
35632
  if (raw.init.url === void 0) {
@@ -35595,7 +35652,7 @@ function validateInnerRequest(raw) {
35595
35652
  }
35596
35653
  return raw;
35597
35654
  }
35598
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35655
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35599
35656
  }
35600
35657
  function assertNonEmptyKeyArray(value, label) {
35601
35658
  if (!Array.isArray(value)) {
@@ -35680,6 +35737,13 @@ function validateInnerResponse(raw) {
35680
35737
  assertObject(raw.values, "inner.values");
35681
35738
  return raw;
35682
35739
  }
35740
+ if (op === "read_dom") {
35741
+ if (raw.values === void 0) {
35742
+ throw new ProtocolError("inner.values: missing on read_dom response");
35743
+ }
35744
+ assertStringMap(raw.values, "inner.values");
35745
+ return raw;
35746
+ }
35683
35747
  if (op === "download") {
35684
35748
  assertObject(raw.value, "inner.value");
35685
35749
  assertString(raw.value.path, "inner.value.path");
@@ -36019,6 +36083,13 @@ async function buildServerHello(opts) {
36019
36083
  jsonPointer: d.jsonPointer
36020
36084
  }));
36021
36085
  }
36086
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36087
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36088
+ name: d.name,
36089
+ selector: d.selector,
36090
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36091
+ }));
36092
+ }
36022
36093
  return hello;
36023
36094
  }
36024
36095
 
@@ -36108,7 +36179,8 @@ async function startHost(opts) {
36108
36179
  captureHeaders: opts.ownCaptureHeaders,
36109
36180
  indexedDbScopes: opts.ownIndexedDbScopes,
36110
36181
  localStoragePointers: opts.ownLocalStoragePointers,
36111
- sessionStoragePointers: opts.ownSessionStoragePointers
36182
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36183
+ domSelectors: opts.ownDomSelectors
36112
36184
  });
36113
36185
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36114
36186
  let extensionWs = null;
@@ -36343,6 +36415,7 @@ async function startPeer(opts) {
36343
36415
  sessionStorageKeys: opts.sessionStorageKeys,
36344
36416
  captureHeaders: opts.captureHeaders,
36345
36417
  indexedDbScopes: opts.indexedDbScopes,
36418
+ domSelectors: opts.domSelectors,
36346
36419
  localStoragePointers: opts.localStoragePointers,
36347
36420
  sessionStoragePointers: opts.sessionStoragePointers
36348
36421
  });
@@ -36750,6 +36823,11 @@ var FetchproxyServer = class {
36750
36823
  key: d.key,
36751
36824
  jsonPointer: d.jsonPointer
36752
36825
  })),
36826
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36827
+ name: d.name,
36828
+ selector: d.selector,
36829
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36830
+ })),
36753
36831
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36754
36832
  // adapter was about to set these to the same numbers anyway; the
36755
36833
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36870,6 +36948,7 @@ var FetchproxyServer = class {
36870
36948
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36871
36949
  ownLocalStoragePointers: this.opts.localStoragePointers,
36872
36950
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36951
+ ownDomSelectors: this.opts.domSelectors,
36873
36952
  onPairCode: this.opts.onPairCode
36874
36953
  });
36875
36954
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -36897,7 +36976,8 @@ var FetchproxyServer = class {
36897
36976
  captureHeaders: this.opts.captureHeaders,
36898
36977
  indexedDbScopes: this.opts.indexedDbScopes,
36899
36978
  localStoragePointers: this.opts.localStoragePointers,
36900
- sessionStoragePointers: this.opts.sessionStoragePointers
36979
+ sessionStoragePointers: this.opts.sessionStoragePointers,
36980
+ domSelectors: this.opts.domSelectors
36901
36981
  });
36902
36982
  this.peerHandle.onInner((inner) => this.onInner(inner));
36903
36983
  this.peerHandle.onRenegotiate(() => {
@@ -37872,6 +37952,46 @@ var FetchproxyServer = class {
37872
37952
  await this.sendInnerFrame(inner);
37873
37953
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37874
37954
  }
37955
+ /**
37956
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
37957
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
37958
+ * match a declared `domSelectors` entry. The extension reads each
37959
+ * declared selector from the matched tab's DOM (isolated-world
37960
+ * `querySelector`, value or attribute) — no page-JS execution.
37961
+ *
37962
+ * Returns a `Record<string, string>` of `name → value`, with names
37963
+ * whose element (or attribute) was absent omitted. Throws
37964
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
37965
+ * developer mistakes (undeclared capability, undeclared name).
37966
+ */
37967
+ async readDom(opts) {
37968
+ if (!this.opts.capabilities.includes("read_dom")) {
37969
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
37970
+ }
37971
+ await this.ensureConnected();
37972
+ this.throwIfPendingPair();
37973
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
37974
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
37975
+ }
37976
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
37977
+ if (opts.subdomain !== void 0)
37978
+ assertSubdomainLabel(opts.subdomain);
37979
+ const baseDomain = this.resolveBaseDomain(opts.domain);
37980
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
37981
+ const origin = `https://${host}`;
37982
+ const id = this.nextRequestId++;
37983
+ const inner = {
37984
+ type: "request",
37985
+ id,
37986
+ op: "read_dom",
37987
+ init: { origin, names: [...opts.names] }
37988
+ };
37989
+ const pending = new Promise((resolve, reject) => {
37990
+ this.pendingStorage.set(id, { resolve, reject });
37991
+ });
37992
+ await this.sendInnerFrame(inner);
37993
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
37994
+ }
37875
37995
  assertScopeSubset(requested, declared, label) {
37876
37996
  const undeclared = undeclaredKeys(requested, declared);
37877
37997
  if (undeclared.length > 0) {
@@ -37943,7 +38063,7 @@ var FetchproxyServer = class {
37943
38063
  if (storageCb) {
37944
38064
  this.pendingStorage.delete(inner.id);
37945
38065
  if (inner.ok) {
37946
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38066
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
37947
38067
  storageCb.resolve({ ...inner.values });
37948
38068
  } else {
37949
38069
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38268,7 +38388,7 @@ var BootstrapDisabledError = class extends Error {
38268
38388
  // package.json
38269
38389
  var package_default = {
38270
38390
  name: "zola-mcp",
38271
- version: "1.4.4",
38391
+ version: "1.5.0",
38272
38392
  mcpName: "io.github.chrischall/zola-mcp",
38273
38393
  description: "Zola wedding MCP server for Claude",
38274
38394
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38314,7 +38434,7 @@ var package_default = {
38314
38434
  "test:watch": "vitest"
38315
38435
  },
38316
38436
  dependencies: {
38317
- "@chrischall/mcp-utils": "^0.12.0",
38437
+ "@chrischall/mcp-utils": "^0.13.0",
38318
38438
  "@fetchproxy/bootstrap": "^1.3.0",
38319
38439
  "@fetchproxy/server": "^1.3.0",
38320
38440
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38325,7 +38445,7 @@ var package_default = {
38325
38445
  "@types/node": "^26.0.0",
38326
38446
  "@vitest/coverage-v8": "^4.1.8",
38327
38447
  esbuild: "^0.28.0",
38328
- typescript: "^6.0.3",
38448
+ typescript: "^7.0.2",
38329
38449
  vitest: "^4.1.8"
38330
38450
  }
38331
38451
  };
@@ -40495,7 +40615,7 @@ function registerEventInvitationTools(server) {
40495
40615
  }
40496
40616
 
40497
40617
  // src/index.ts
40498
- var VERSION = "1.4.4";
40618
+ var VERSION = "1.5.0";
40499
40619
  await runMcp({
40500
40620
  name: "zola-mcp",
40501
40621
  version: VERSION,
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { registerWebsiteThemeTools } from './tools/website-theme.js';
12
12
  import { registerRegistryItemTools } from './tools/registry-items.js';
13
13
  import { registerInvitationTools } from './tools/invitations.js';
14
14
  import { registerEventInvitationTools } from './tools/event-invitations.js';
15
- const VERSION = '1.4.4'; // x-release-please-version
15
+ const VERSION = '1.5.0'; // x-release-please-version
16
16
  await runMcp({
17
17
  name: 'zola-mcp',
18
18
  version: VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zola-mcp",
3
- "version": "1.4.4",
3
+ "version": "1.5.0",
4
4
  "mcpName": "io.github.chrischall/zola-mcp",
5
5
  "description": "Zola wedding MCP server for Claude",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -46,7 +46,7 @@
46
46
  "test:watch": "vitest"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^0.12.0",
49
+ "@chrischall/mcp-utils": "^0.13.0",
50
50
  "@fetchproxy/bootstrap": "^1.3.0",
51
51
  "@fetchproxy/server": "^1.3.0",
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -57,7 +57,7 @@
57
57
  "@types/node": "^26.0.0",
58
58
  "@vitest/coverage-v8": "^4.1.8",
59
59
  "esbuild": "^0.28.0",
60
- "typescript": "^6.0.3",
60
+ "typescript": "^7.0.2",
61
61
  "vitest": "^4.1.8"
62
62
  }
63
63
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/zola-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.4.4",
9
+ "version": "1.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "zola-mcp",
14
- "version": "1.4.4",
14
+ "version": "1.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -0,0 +1,120 @@
1
+ ---
2
+ name: zola-api
3
+ description: >-
4
+ Query or update Zola wedding-planning data (vendors, budget, guests, seating,
5
+ events/RSVPs, registry, gift tracker, inquiries, wedding website) straight
6
+ from a shell with curl against mobile-api.zola.com, instead of running the
7
+ zola-mcp server. Use when you want Zola data without the MCP, in a script,
8
+ or on a machine where the MCP isn't installed. Triggers on "check Zola",
9
+ "Zola vendors/budget/guests/RSVP/seating/registry", or any Zola wedding
10
+ data request that should hit the API directly.
11
+ ---
12
+
13
+ # Zola mobile API via curl (no MCP)
14
+
15
+ Zola's mobile API (`mobile-api.zola.com` — the same surface the iOS/iPad app
16
+ and `zola-mcp` use) is a plain Bearer-JWT REST API reachable directly from a
17
+ server or shell — no browser bridge needed. This skill shells out to `curl`
18
+ with the JWT in an `Authorization: Bearer` header, exactly as
19
+ `zola-mcp`'s `src/client.ts` does.
20
+
21
+ ## One-time setup: get the refresh token
22
+
23
+ You need Zola's long-lived (~1 year) refresh JWT — the `usr` cookie from a
24
+ signed-in `zola.com` session. Same credential `zola-mcp` uses:
25
+
26
+ ```sh
27
+ # Prefer the env var zola-mcp itself reads (check its .env first):
28
+ grep -h ZOLA_REFRESH_TOKEN ~/git/zola-mcp/.env 2>/dev/null
29
+ export ZOLA_REFRESH_TOKEN='eyJhbGciOi...' # or export directly if you have it
30
+ ```
31
+
32
+ If you don't have it yet: sign into zola.com, open DevTools → Application →
33
+ Cookies → `https://www.zola.com`, copy the `usr` value. (`zola-mcp` also has a
34
+ fetchproxy fallback that reads this cookie from a signed-in browser tab — see
35
+ its README — but that's the MCP's path, not this skill's; this skill assumes
36
+ you already have the token in hand.)
37
+
38
+ ## Core call pattern
39
+
40
+ Every mobile-api call needs a short-lived (30 min) **session token**, minted
41
+ from the refresh token, plus a fixed set of headers (CloudFront WAF requires
42
+ `x-zola-session-id` on every request — omit it and you get a 403).
43
+
44
+ ```sh
45
+ BASE=https://mobile-api.zola.com
46
+ DEVICE_SESSION_ID=$(uuidgen | tr 'a-z' 'A-Z') # one per "session"; reuse across calls
47
+ UA='Zola/42.5.0 (iPad; iOS 26.4; Scale/2.0)'
48
+
49
+ # 1. Mint a 30-min session token from the refresh token
50
+ SESSION_TOKEN=$(curl -sS -X POST "$BASE/v3/sessions/refresh" \
51
+ -H 'content-type: application/json' -H 'accept: application/json' \
52
+ -H "x-zola-platform-type: iphone_app" -H "x-zola-session-id: $DEVICE_SESSION_ID" \
53
+ -H "user-agent: $UA" \
54
+ -d "{\"token\":\"$ZOLA_REFRESH_TOKEN\"}" | jq -r '.data.session_token')
55
+
56
+ # 2. Every subsequent call reuses $SESSION_TOKEN until it expires (~30 min),
57
+ # then re-run step 1. All calls carry the same 4 headers:
58
+ curl -sS -X GET "$BASE/v3/users/me/context" \
59
+ -H "authorization: Bearer $SESSION_TOKEN" \
60
+ -H "x-zola-platform-type: iphone_app" -H "x-zola-session-id: $DEVICE_SESSION_ID" \
61
+ -H "user-agent: $UA" | jq '.data'
62
+ ```
63
+
64
+ Wrap this in a shell function or export the 4 headers once — every recipe in
65
+ `references/mobile-api-endpoints.md` reuses `$BASE`, `$SESSION_TOKEN`,
66
+ `$DEVICE_SESSION_ID`, `$UA`. POST/PUT bodies additionally need
67
+ `-H 'content-type: application/json'`.
68
+
69
+ (The app also sends a fifth header, `x-zola-user-session-id`, derived from a
70
+ claim inside the session JWT — `zola-mcp` only attaches it when the decode
71
+ succeeds, so it's not load-bearing; every endpoint below works without it.)
72
+
73
+ ## The one rule: resolve context first
74
+
75
+ Most write/list endpoints are scoped by numeric IDs, never inferred — call
76
+ `GET /v3/users/me/context` once per session and keep the three IDs around:
77
+
78
+ ```sh
79
+ CTX=$(curl -sS "$BASE/v3/users/me/context" -H "authorization: Bearer $SESSION_TOKEN" \
80
+ -H "x-zola-platform-type: iphone_app" -H "x-zola-session-id: $DEVICE_SESSION_ID" -H "user-agent: $UA")
81
+ ACCT=$(jq -r '.data.wedding_account.wedding_account_id' <<<"$CTX")
82
+ REG=$(jq -r '.data.registry.id' <<<"$CTX")
83
+ WEDDING_ID=$(jq -r '.data.wedding.wedding_id' <<<"$CTX")
84
+ ```
85
+
86
+ `ACCT` (`wedding_account_id`) feeds guest/event/website-content paths; `REG`
87
+ (`registry_id`) feeds registry/gift-tracker paths.
88
+
89
+ ## Ready-to-run endpoints
90
+
91
+ `references/mobile-api-endpoints.md` has real, ready-to-run `curl` + `jq`
92
+ recipes for every one of the 30 `zola-mcp` tools' underlying calls
93
+ (vendors, budget, guests, seating, inquiries, events/RSVPs/gifts/registry,
94
+ discover/storefronts, registry items, invitations/card-projects, website
95
+ pages, website theme/customization, website content). Transcribed straight
96
+ from `src/tools/*.ts` — same paths, same request bodies.
97
+
98
+ ## Mutation gotcha: most writes are read-modify-write
99
+
100
+ Zola's write endpoints replace whole objects, not deltas — the same tools
101
+ that build a request first re-GET the current record and only patch the
102
+ requested fields, or a full-state write can wipe unrelated fields.
103
+ `docs/zola-api-quirks.md` in this repo documents the worst offenders (a
104
+ `header_font` write nulling every other website color; guest writes needing
105
+ to preserve `event_invitations` verbatim or lose them). Follow the
106
+ read-modify-write shape shown per-endpoint in the references file — don't
107
+ send a bare partial body to `PUT`/`POST` write endpoints.
108
+
109
+ ## Output / error contract
110
+
111
+ - A 2xx body is `{"data": ...}` for nearly every endpoint — pipe to
112
+ `jq '.data'`.
113
+ - `401` — session token expired; re-mint via `POST /v3/sessions/refresh`
114
+ (step 1 above) and retry once.
115
+ - `429` — rate limited; back off ~2s and retry once.
116
+ - Any other non-2xx — the body is a JSON error envelope; read it directly,
117
+ it is not redacted here (unlike the MCP, which redacts secrets from error
118
+ text — don't paste raw error bodies containing the session token anywhere
119
+ public).
120
+ - This project (`zola-mcp`) is developed and maintained by AI (Claude).
@@ -0,0 +1,444 @@
1
+ # Zola mobile-api endpoints (curl + jq)
2
+
3
+ All paths are relative to `$BASE=https://mobile-api.zola.com`. Every call
4
+ carries `authorization: Bearer $SESSION_TOKEN`, `x-zola-platform-type:
5
+ iphone_app`, `x-zola-session-id: $DEVICE_SESSION_ID`, `user-agent: $UA` (see
6
+ `SKILL.md`); POST/PUT/DELETE-with-body also need `-H 'content-type:
7
+ application/json'`. `$ACCT` = `wedding_account_id`, `$REG` = `registry_id`,
8
+ `$WEDDING_ID` = `wedding_id`, all three from `GET /v3/users/me/context` (see
9
+ `SKILL.md`'s "resolve context first" section). Response envelope is `{"data": ...}`
10
+ unless noted. Paths/bodies below are transcribed from `src/tools/*.ts` —
11
+ each section names its source file.
12
+
13
+ Shorthand used below:
14
+
15
+ ```sh
16
+ H=(-H "authorization: Bearer $SESSION_TOKEN" -H "x-zola-platform-type: iphone_app" \
17
+ -H "x-zola-session-id: $DEVICE_SESSION_ID" -H "user-agent: $UA")
18
+ HJ=("${H[@]}" -H 'content-type: application/json')
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Context (`src/client.ts`)
24
+
25
+ ```sh
26
+ curl -sS "${H[@]}" "$BASE/v3/users/me/context" | jq '.data'
27
+ # .data.user.id, .data.wedding_account.wedding_account_id,
28
+ # .data.wedding.{wedding_id,wedding_date,slug}, .data.registry.id
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Vendors (`src/tools/vendors.ts`)
34
+
35
+ **List booked vendors** — `POST /v3/account-vendors/booked-list` body `{}`:
36
+
37
+ ```sh
38
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/account-vendors/booked-list" -d '{}' \
39
+ | jq '.data.booked_vendors'
40
+ ```
41
+
42
+ **Search vendors (typeahead)** — `POST /v3/reference-vendors/typeahead-taxonomy`:
43
+
44
+ ```sh
45
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/reference-vendors/typeahead-taxonomy" \
46
+ -d '{"query":"Acme Photography","taxonomy_key":"wedding-photographers"}' | jq '.data'
47
+ # taxonomy_key default: wedding-venues. Other keys: wedding-planners, wedding-bands-djs, ...
48
+ ```
49
+
50
+ **Book a vendor** — find an unbooked slot from `booked-list` for the
51
+ `vendor_type`, then `PUT /v5/account-vendors/vendor` (note: **v5**, not v3):
52
+
53
+ ```sh
54
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v5/account-vendors/vendor" -d '{
55
+ "uuid": "<slot-uuid-from-booked-list>", "id": 0, "vendor_type": "PHOTOGRAPHER",
56
+ "booked": true, "booking_source": "BOOKED_VENDORS",
57
+ "price_cents": 350000, "event_date": null,
58
+ "sync_with_budget_tool_enabled": true, "facet_keys": [],
59
+ "reference_vendor_request": {
60
+ "id": null, "name": "Acme Photography", "email": null, "phone": null,
61
+ "address": {"city": "Charlotte", "state_province_region": "NC"}
62
+ }
63
+ }' | jq '.data'
64
+ ```
65
+
66
+ **Update a booked vendor** — same `PUT /v5/account-vendors/vendor`, but
67
+ read-modify-write: GET `booked-list`, find by `uuid`, keep `id`/`vendor_type`,
68
+ only patch the fields you're changing (name/city/state/email/price/date
69
+ default to the current value).
70
+
71
+ **Unbook a vendor** — `POST /v3/account-vendors/vendor/unbook`:
72
+
73
+ ```sh
74
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/account-vendors/vendor/unbook" \
75
+ -d '{"uuid":"<vendor-uuid>"}'
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Budget (`src/tools/budget.ts`)
81
+
82
+ **Get budget** — `GET /v3/budgets`:
83
+
84
+ ```sh
85
+ curl -sS "${H[@]}" "$BASE/v3/budgets" | jq '{
86
+ budgeted_cents: .data.budgeted_cents, cost_cents: .data.cost_cents,
87
+ paid_cents: .data.paid_cents, balance_due_cents: .data.balance_due_cents,
88
+ items: [.data.taxonomy_nodes[].items[] | {uuid, title, cost_cents, paid_cents}]
89
+ }'
90
+ ```
91
+
92
+ **Update a budget item** — read-modify-write: `GET /v3/budgets`, find the item
93
+ by `uuid`, then `PUT /v3/budgets/items` with every required field (server
94
+ rejects a bare partial):
95
+
96
+ ```sh
97
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/budgets/items" -d '{
98
+ "item_uuid": "<uuid>", "taxonomy_node_uuid": "<from-get>",
99
+ "estimated_cost_cents": <from-get>, "actual_cost_cents": 250000,
100
+ "note": "Deposit paid", "item_type": "<from-get, e.g. VENUE>",
101
+ "title": "<from-get>"
102
+ }' | jq '.data'
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Guests (`src/tools/guests.ts`)
108
+
109
+ **List guests** — `POST /v3/guestlists/directory/wedding-accounts/$ACCT` body
110
+ `{"sort_by_name_asc": true}`. Guest shape is **flat** (fields directly on the
111
+ guest object, no `{guest:{...}}` wrapper):
112
+
113
+ ```sh
114
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/guestlists/directory/wedding-accounts/$ACCT" \
115
+ -d '{"sort_by_name_asc": true}' \
116
+ | jq '.data.guest_groups[] | {guest_group_id, guests: [.guests[] | {guest_id, first_name, family_name, rsvp}]}'
117
+ ```
118
+
119
+ **Add a guest group** — `POST /v3/guestlists/groups`:
120
+
121
+ ```sh
122
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/guestlists/groups" -d '{
123
+ "wedding_account_id": '"$ACCT"',
124
+ "guests": [{
125
+ "first_name": "Mike", "family_name": "Smith", "relationship_type": "PRIMARY",
126
+ "source": "IOS", "email_address": "", "mobile_phone": "", "affiliation": "PRIMARY_FRIEND",
127
+ "tier": "A", "country_code": "US", "prefix": "", "middle_name": "", "suffix": "",
128
+ "home_phone": "", "address1": "", "address2": "", "city": "", "state_province": "",
129
+ "postal_code": "", "event_invitations": [], "tags": []
130
+ }],
131
+ "guest_group_affiliation": "PRIMARY_FRIEND", "guest_group_tier": "A",
132
+ "guest_group_uuid": "", "envelope_recipient": "", "invited": true,
133
+ "invitation_sent": false, "save_the_date_sent": false,
134
+ "rsvp_question_answers": [], "gift_count": 0
135
+ }' | jq '.data'
136
+ ```
137
+
138
+ **Update a guest group's address** — read-modify-write via
139
+ `PUT /v3/guestlists/groups/wedding-accounts/$ACCT/bulk/directory`. **Must**
140
+ preserve each guest's existing `event_invitations` verbatim (a group that
141
+ sends `event_invitations: []` wipes them, per `docs/zola-api-quirks.md` §5):
142
+
143
+ ```sh
144
+ # 1. GET the directory (above), find the target group by guest_group_id,
145
+ # keep its `guests[]` array as-is except the address fields you're changing.
146
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/guestlists/groups/wedding-accounts/$ACCT/bulk/directory" \
147
+ -d '{"updated_guest_groups": [ <full-group-object-with-patched-guest-addresses> ]}' \
148
+ | jq '.data'
149
+ ```
150
+
151
+ **Remove a guest group** — `PUT /v3/guestlists/groups/wedding-accounts/$ACCT/delete`:
152
+
153
+ ```sh
154
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/guestlists/groups/wedding-accounts/$ACCT/delete" \
155
+ -d '{"wedding_account_id": '"$ACCT"', "guest_group_ids": [<id>]}'
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Seating (`src/tools/seating.ts`)
161
+
162
+ ```sh
163
+ curl -sS "${H[@]}" "$BASE/v3/seating-charts/summaries" | jq '.' # NOT wrapped in `data`
164
+ curl -sS "${H[@]}" "$BASE/v3/seating-charts/<chart-uuid>" | jq '.'
165
+ ```
166
+
167
+ **Unseated guests** — `POST /v3/guestlists/directory/wedding-accounts/$ACCT`
168
+ (same call as guests list), filter client-side for
169
+ `.guests[].seating_chart_seat == null`.
170
+
171
+ **Assign a seat** — `PUT /v3/seating-charts/seats`:
172
+
173
+ ```sh
174
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/seating-charts/seats" -d '{
175
+ "guest_uuid": "<guest-uuid>", "seat_uuid": "<seat-uuid>",
176
+ "table_uuid": "<table-uuid>", "seating_chart_uuid": "<chart-uuid>"
177
+ }' | jq '.'
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Inquiries (`src/tools/inquiries.ts`)
183
+
184
+ ```sh
185
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/inquiries/unified-inquiries" -d '{}' \
186
+ | jq '.data[].inquiry_summaries[] | {inquiry_uuid, vendor_name: .vendor_card.vendor_name, unread, status_text}'
187
+
188
+ curl -sS "${H[@]}" "$BASE/v3/inquiries/<inquiry-uuid>/conversation" | jq '.data.messages'
189
+
190
+ curl -sS "${H[@]}" -X PUT "$BASE/v3/inquiries/<inquiry-uuid>/conversation/read"
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Events, RSVPs, gifts, registry (`src/tools/events.ts`)
196
+
197
+ ```sh
198
+ curl -sS "${H[@]}" "$BASE/v3/websites/events/wedding-accounts/$ACCT/groups" \
199
+ | jq '.data[].events[]' # event_entity_id, name, start_at, num_guests_*
200
+
201
+ curl -sS "${H[@]}" "$BASE/v3/websites/events/track-rsvps" | jq '.data.modules'
202
+
203
+ curl -sS "${H[@]}" "$BASE/v3/gift_tracker/$REG" | jq '.data | del(.info_modules)'
204
+
205
+ curl -sS "${H[@]}" "$BASE/v4/shop/registry?registry_id=$REG&updated_modules=true" | jq '.data'
206
+ ```
207
+
208
+ **Update an event** — read-modify-write: GET the groups above, find by
209
+ `event_entity_id`, then `PUT /v3/websites/events/{event_id}` with the full
210
+ event object (patch only the fields you're changing; everything else —
211
+ `type`, `uuid`, `wedding_account_id`, counts, `meal_options` — round-trips
212
+ from the GET):
213
+
214
+ ```sh
215
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/events/<event_id>" -d '{
216
+ "event_entity_id": <event_id>, "uuid": "<from-get>", "wedding_account_id": '"$ACCT"',
217
+ "type": "<from-get>", "name": "Reception", "start_at": "<from-get>", "end_at": "<from-get>",
218
+ "timezone": "<from-get>", "venue_name": "The Grand Hall", "address1": "", "address2": "",
219
+ "city": "Charlotte", "state_province": "NC", "postal_code": "", "country_code": "US",
220
+ "note": "", "attire": "Black tie", "collect_rsvps": true, "public": <from-get>,
221
+ "display_order": 0, "num_guests_attending": <from-get>, "num_guests_declined": <from-get>,
222
+ "num_guests_not_responded": <from-get>, "meal_options": <from-get>,
223
+ "rsvp_questions": [], "add_booked_vendor": false
224
+ }' | jq '.data'
225
+ ```
226
+
227
+ **Event invitations (per-guest, read-modify-write)** — from
228
+ `src/tools/event-invitations.ts`. Each guest carries an `event_invitations`
229
+ array; invite = append `{"event_id":<id>,"id":null,"rsvp_type":"NO_RESPONSE"}`,
230
+ uninvite = drop that element, **preserve every other element verbatim**
231
+ (same wipe risk as the guest-address write above). Write through the same
232
+ `PUT /v3/guestlists/groups/wedding-accounts/$ACCT/bulk/directory` used for
233
+ guest writes, sending `{"updated_guest_groups": [...]}` with full group
234
+ objects (each guest keeps its full shape, only `event_invitations` patched).
235
+
236
+ ---
237
+
238
+ ## Discover / storefronts (`src/tools/discover.ts`)
239
+
240
+ ```sh
241
+ curl -sS "${H[@]}" "$BASE/v4/your-wedding" | jq '.data'
242
+
243
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/storefronts/search" -d '{
244
+ "taxonomy_node_id": 2, "city": "Charlotte", "state": "NC",
245
+ "limit": 24, "offset": 0, "facets": {},
246
+ "metro_types": ["HOME","HOME_SERVICE","AWAY"], "metros": [],
247
+ "exclude_inquired_storefronts": false, "exclude_booked_storefronts": false,
248
+ "boost_featured_storefronts": false, "suggested_vendors_for_inquiry_limit": 12
249
+ }' | jq '.data'
250
+ # taxonomy_node_id: 1=Venues 2=Photographers 3=Florists 7=Planners 9=Bands/DJs
251
+
252
+ curl -sS "${H[@]}" "$BASE/v3/storefronts/<storefront-uuid>" | jq '.data'
253
+ curl -sS "${H[@]}" "$BASE/v3/favorites/" | jq '.data'
254
+ ```
255
+
256
+ ---
257
+
258
+ ## Registry items (`src/tools/registry-items.ts`)
259
+
260
+ ```sh
261
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/categories/<category_id>/entities" \
262
+ -d '{"offset":0,"limit":50,"registry_id":"'"$REG"'"}' | jq '.data'
263
+
264
+ # Default collection id: `.data.default_collection_id` from the registry GET
265
+ # above, or the first `groups[].modules[]` entry with `type == "COLLECTION"`.
266
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/registries/$REG/collections/<collection_id>" \
267
+ -d '{"sku_id":"<sku>","quantity":1,"most_wanted":false,"enable_group_gifting":false}' \
268
+ | jq '.data'
269
+
270
+ # Full replace — all fields required:
271
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/registries/$REG/items/<collection_item_id>" -d '{
272
+ "quantity": 2, "group_gift": false, "marked_fulfilled": false,
273
+ "personal_note": "", "most_wanted": true, "collection_id": "<collection_id>"
274
+ }' | jq '.data'
275
+
276
+ curl -sS "${H[@]}" -X DELETE "$BASE/v3/registries/$REG/items/<collection_item_id>"
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Website pages & settings (`src/tools/website.ts`)
282
+
283
+ ```sh
284
+ curl -sS "${H[@]}" "$BASE/v3/websites/pages/wedding-accounts/full" | jq '.data'
285
+
286
+ curl -sS "${H[@]}" -X PUT "$BASE/v3/websites/pages/<page_id>/hidden/true" # or /false
287
+
288
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/pages/wedding-accounts/$ACCT/reorder" \
289
+ -d '{"ids": [<page_id_1>, <page_id_2>, ...]}' | jq '.data'
290
+
291
+ # Partial patch is fine here (unlike most write endpoints):
292
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/pages-v2/<page_id>" \
293
+ -d '{"page_id": <page_id>, "title": "Our Story"}' | jq '.data'
294
+ ```
295
+
296
+ **Wedding settings** — read via `GET /v3/users/me/context` → `.data.wedding`;
297
+ write is read-modify-write (all fields required) to
298
+ `PUT /v3/weddings/{wedding_id}`:
299
+
300
+ ```sh
301
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/weddings/$WEDDING_ID" -d '{
302
+ "wedding_id": '"$WEDDING_ID"', "account_id": '"$ACCT"',
303
+ "slug": "<from-get>", "owner_first_name": "<from-get>", "owner_last_name": "<from-get>",
304
+ "partner_first_name": "<from-get>", "partner_last_name": "<from-get>",
305
+ "title": "Alex & Jordan", "wedding_date": "<from-get>", "hashtag": "#alexandjordan2026",
306
+ "enable_search_engine": true, "enable_search_zola": true,
307
+ "city": "Charlotte", "state_province": "NC", "guest_count": 150
308
+ }' | jq '.data'
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Website theme & customization (`src/tools/website-theme.ts`)
314
+
315
+ ```sh
316
+ curl -sS "${H[@]}" "$BASE/v3/themes/current" | jq '.data'
317
+ curl -sS "${H[@]}" "$BASE/v3/websites/website-customizations/context" | jq '.data.current_style_customizations'
318
+
319
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/themes/search" \
320
+ -d '{"limit":50,"offset":0,"theme_layout_types":["MULTI_PAGE"]}' | jq '.data'
321
+
322
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/themes/current" \
323
+ -d '{"theme_key":"galata","theme_layout_type":"MULTI_PAGE"}' | jq '.data'
324
+ ```
325
+
326
+ **Update colors/fonts** — `POST /v3/websites/website-customizations/context`.
327
+ **Gotcha (see `docs/zola-api-quirks.md` §1–4):** changing `header_font`
328
+ (i.e. `header_font_family_id`) wipes every *other* active color unless you
329
+ re-send them in the same POST — GET the current state first and bundle its
330
+ non-null `accent_color` / `background_color` / `body.color` /
331
+ `navigation_customization.background_color` into the body. `body_font`'s
332
+ `font_family_id` only accepts `68` (Libre Baskerville) or `198` (Circular) —
333
+ any other value 500s. `header_color` / `nav_font_color` are **not** writable
334
+ on this API at all (web-api-only, cookie+CSRF auth — out of scope here).
335
+
336
+ ```sh
337
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/websites/website-customizations/context" -d '{
338
+ "accent_color": "C9A66B",
339
+ "background_color": "FFFFFF",
340
+ "body_font": {"color": "222222"},
341
+ "navigation_customization": {"background_color": "FFFFFF"}
342
+ }' | jq '.data'
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Website content: FAQs / home sections / POIs / travel (`src/tools/website-content.ts`)
348
+
349
+ All four follow the same list/add/update/remove shape;
350
+ `{wedding_account_id: $ACCT}` and an `_entity_id: 0` (create) or the real id
351
+ (update) prefix every write body. Deletes share one path shape:
352
+ `DELETE /v3/websites/{pages}/{entities}/{entity_id}/wedding-accounts/$ACCT`
353
+ where `{pages}` is looked up per-type from
354
+ `GET /v3/websites/pages/wedding-accounts/full` (`.data.{home,faq,poi,travel}_page.page_id`).
355
+
356
+ ```sh
357
+ # FAQs
358
+ curl -sS "${H[@]}" "$BASE/v3/websites/faqs/wedding-accounts/$ACCT" | jq '.data'
359
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/websites/faqs" -d '{
360
+ "wedding_account_id": '"$ACCT"', "faq_entity_id": 0,
361
+ "question": "What is the dress code?", "answer": "Cocktail attire", "display_order": 0
362
+ }' | jq '.data'
363
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/faqs/<faq_entity_id>" -d '{
364
+ "wedding_account_id": '"$ACCT"', "faq_entity_id": <faq_entity_id>,
365
+ "question": "...", "answer": "...", "display_order": 0
366
+ }' | jq '.data'
367
+ curl -sS "${H[@]}" -X DELETE "$BASE/v3/websites/pages/<faq_page_id>/entities/<faq_entity_id>/wedding-accounts/$ACCT"
368
+
369
+ # Home page story sections — same shape, path `/v3/websites/home-sections[...]`,
370
+ # id field `homepage_entity_id`, extra `hidden` boolean, `title`+`subtitle`+`description`.
371
+
372
+ # Points of interest — `/v3/websites/points-of-interest[...]`, id field
373
+ # `poi_entity_id`; fields: title, description, address1/2, city, state_province,
374
+ # postal_code, country_code, latitude, longitude, google_place_id, contact_phone, url.
375
+
376
+ # Travel items — `/v3/websites/travel[...]`, id field `travel_entity_id`;
377
+ # fields: type (HOTEL|FLIGHT|TRAIN|BUS|CAR|OTHER), name, note, code,
378
+ # address1/2, city, state_province, postal_code, country_code, latitude,
379
+ # longitude, google_place_id, contact_number, email_address, url, source
380
+ # (GOOGLE_PLACES|MANUAL), timezone, display_order.
381
+ ```
382
+
383
+ Home-section/POI/travel `list`/`add`/`update`/`remove` all mirror the FAQ
384
+ calls above 1:1 (same verbs, same `wedding_account_id` prefix, same delete
385
+ shape) — see `src/tools/website-content.ts` for the exact field names per
386
+ type if you need the full body.
387
+
388
+ ---
389
+
390
+ ## Invitations / card projects (`src/tools/invitations.ts`)
391
+
392
+ ```sh
393
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-projects/search_request" -d '{
394
+ "completed": false, "limit": 30, "card_suite_uuids": [], "card_suite_ids": [],
395
+ "offset": 0, "fetch_customizations": true, "include_deleted": false,
396
+ "medium": ["PAPER","MAGNET","DIGITAL"], "single_sample": false
397
+ }' | jq '.data'
398
+
399
+ curl -sS "${H[@]}" "$BASE/v3/card-projects/<project_uuid>" | jq '.data'
400
+ curl -sS "${H[@]}" "$BASE/v3/card-projects/<project_uuid>/validate" | jq '.data'
401
+ curl -sS "${H[@]}" "$BASE/v3/card-projects/<project_uuid>/project-guest-groups" | jq '.data'
402
+ curl -sS "${HJ[@]}" -X POST "$BASE/v4/card-catalog/suites/details/<suite_uuid>" | jq '.data'
403
+
404
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-catalog/search/faceted" -d '{
405
+ "lead_card_types": [], "include_updated_proof_module": true, "limit": 50, "offset": 0,
406
+ "digital_suite": false, "include_lead_card_type_metadata": true,
407
+ "lead_card_type": "INVITATION", "include_module": true
408
+ }' | jq '.data'
409
+
410
+ curl -sS "${H[@]}" "$BASE/v3/favorites/card-suites/" | jq '.data'
411
+ curl -sS "${H[@]}" "$BASE/v3/websites/rsvps/wedding-accounts/$ACCT" | jq '.data'
412
+
413
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-projects" -d '{
414
+ "quantity": 150, "lead_variation_uuid": "<variation-uuid>",
415
+ "extra_customizable": false, "account_id": '"$ACCT"', "suite_uuid": "<suite-uuid>"
416
+ }' | jq '.data'
417
+
418
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/<project_uuid>" -d '{
419
+ "customizations": {"<customization_uuid>": {"variation_uuid": "<new-variation-uuid>"}}
420
+ }' | jq '.data'
421
+
422
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/<project_uuid>/project-guest-groups" -d '{
423
+ "guest_group_requests": [{"guest_group_id": <id>, "enabled": true}]
424
+ }' | jq '.data'
425
+
426
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-templates/preview" -d '{
427
+ "variation_uuids": ["<variation-uuid>"], "customizable": true,
428
+ "substitutions": {"first_name": "Alex", "wedding_date": "2026-10-17"}
429
+ }' | jq '.data'
430
+ ```
431
+
432
+ **QR code** — preview returns raw image bytes (Content-Type lies as
433
+ `image/jpeg` even for a PNG — sniff magic bytes if scripting):
434
+
435
+ ```sh
436
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/qrcode/preview" \
437
+ -H 'accept: */*' -d '{"dimension":"MEDIUM","url_type":"CUSTOM","enabled":true,"url":"https://example.com"}' \
438
+ -o qrcode.png
439
+
440
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/<project_uuid>/customization/page/<page_uuid>/qrcode" -d '{
441
+ "dimension": "MEDIUM", "url_type": "CUSTOM", "color": "000000",
442
+ "enabled": true, "url": "https://example.com"
443
+ }' | jq '.data'
444
+ ```