Skip to content

ADR-0010 — Wix API as the content/media plane (matched by id to the capture)

Status: Accepted (direction + spike-validated 2026-07-12; build sliced + deferred) Date: 2026-07-12 Deciders: Cathal Dempsey Related: ADR-0009 §4 (two-plane ingestion — this confirms its conditional "Wix API is the content/media source of truth where available" into a committed decision), ADR-0005 (R2 image pipeline — the migration target), ADR-0008 (per-site worker fleet — the same account-sharding model).

Context

ADR-0009 §4 split ingestion into two planes: the design plane (Playwright capture = the layout/style authority) and the content plane (Wix API = the content/media source of truth "where available"), with the DOM scrape as the section-segmentation backbone. "Where available" was doing load-bearing work — the Wix API was greenfield (no code, no credentials in the repo) and its portfolio viability turned on an unanswered question: do FCR control the sites' Wix accounts?

Two session pains forced the question. The hero-VIDEO migration (Slice 5b) needed to reconstruct video.wixstatic.com/video/<id>/<quality>/mp4/file.mp4 URLs from stored basenames, and the carousel frames (Slice 5a) took three capture traps to recover 8 lazy-mounted slides. The cleaner source is the site's own media library.

Decision

Adopt the Wix REST API as the content/media source-of-truth, matched to the DOM scrape's segmentation BY MEDIA ID. The scrape stays the layout/segmentation backbone (which media id sits in which hero/section — ADR-0009 §4); the API supplies the clean asset for each id (real URLs, original filename, dimensions, colours, alt text). The capture design plane is unchanged — the API never sees the rendered layout.

Two facts settled it (2026-07-12 spike, WCP metaSiteId dd5a7db4-…):

  1. Account model — FCR-owned. The ~1800 portfolio sites live in FCR-owned Wix account(s), so a single API key (or a few, sharded like the worker fleet, ADR-0008) covers the portfolio — no per-site OAuth. This is what makes the API a portfolio plane, not a per-site nicety.
  2. The API returns exactly what the scrape was fighting for. GET /site-media/v1/files?mediaTypes=VIDEO returned WCP's hero video with real MP4 URLs at every resolution (480p/360p/144p + HLS) and its original filename (VID-20251205-WA0007.mp4) — the 480p URL byte-identical to the one Slice 5b hand-reconstructed, now provenance-complete and reconstruction-free. mediaTypes=IMAGE returned 92 images; all 7 captured carousel frame ids matched the API pool (7/7), with filenames + dimensions.

Auth + call shape (to bake into the client)

  • API key created in the API Keys Manager (manage.wix.com/account/api-keys), scoped Manage Site Media (+ Business Info + Blog for the content half), site access restricted per key. Stored WIX_API_KEY / WIX_ACCOUNT_ID in apps/cms/.dev.vars (read via the existing .dev.vars parser convention).
  • Site-level calls: Authorization: <key> + wix-site-id: <siteId> ONLY. Adding wix-account-id to a site-level call → HTTP 400 (see known-patterns "A Wix site-level API call takes wix-site-id only").
  • Endpoint: https://www.wixapis.com/site-media/v1/files (GET, cursor-paged, mediaTypes filter). Query Sites (account-level, for portfolio enumeration) needs the account-level "Get Sites List" scope — the WCP spike key lacked it (403); not needed while the site id is known.

Alternatives considered

  • Capture-scrape only (status quo). Works, but brittle for video (URL reconstruction) and lossy for provenance (no clean filenames; scrape-download every asset). Retained as the segmentation backbone; superseded as the asset source where the API covers it.
  • Per-site OAuth. Would be forced if the sites were clients' independent Wix accounts — impractical at portfolio scale. The FCR-owned account model retires it.

Consequences

Positive

  • Retires the video.wixstatic.com URL reconstruction (Slice 5b) and the scrape-and-download of images — look up by the id we already scrape.
  • Clean provenance in-data (ADR-0005 §6 spirit): original filenames from the source, not derived/normalised guesses.
  • Unlocks the business-info + blog content plane on the same key.

Negative / risks

  • Segmentation still needs the scrape. The API gives the media POOL, not which asset is on which page/section — the by-id match depends on the scrape surfacing the id. Sites where the scrape can't surface an id get no API enrichment.
  • Getting an asset ≠ rendering it. The clean video is now available, but showing it (WCP home's carousel renders slides>video, so the video shows as its still frame) still needs render work — carousel video-slide support, or a variant:video hero. A separate concern from acquisition, tracked under the ADR-0009 hero dimension.
  • Greenfield build. No client code yet; the spike was a throwaway probe.

Build slices

# Slice Status
A Wix media client (apps/cms/scripts/wix-api.mjs): auth + listFiles + searchFiles + by-id lookup + cursor paging. Done 2026-07-12 pt5 — committed client; .dev.vars creds, site-id resolution, cursor paging (paging.limit/paging.cursor), by-id index, pure parseMediaRef/pickVideoUrl/originalNameOf; 22-check network-free --selftest. Site id is derived, not hand-entered (see §"Site id" below). Surfaced the root-folder-scoping correction below.
B Video migration via the API (retires the Slice 5b reconstruction): scrape id → API MP4 url → R2. Done 2026-07-12 pt5 — video branch in transform-seed-images.mjs; WCP hero video → r2:waterfordcountypainters/vid-20251205-wa0007-73c39f8f.mp4 (video/mp4, 3.96 MB, x-amz-meta-original-name: VID-20251205-WA0007.mp4), seed + live D1 (surgical, never a reseed), idempotent. asset-migration-gaps 1 → 0. The R2 key stem is the API's real filename, so provenance is sourced, not derived.
C Image provenance enrichment: replace derived basenames with API filenames/dimensions/alt. Planned. Scoping fact found 2026-07-13: a LOGO carries a name, not a media id — so it is in the pool but invisible to the by-id join. WCP's searchFiles pool holds Waterford County Painters_logo (8b4be4_4651bc7c…~mv2.png, 1267×898), yet the scraper saved it locally as /assets/logos/Waterford_20County_20Painters_logo.png — the URL-encoded display name instead of the media id every other image keeps. That is what makes the deriver report "no wixstatic source" and what made the wiki claim "logos are local-only" (now corrected). Slice C must therefore carry a name-based fallback join for the logo class, and should treat it as a weaker key than the id join (known-patterns "A reconciliation is only as honest as its JOIN KEY"). Payoff: the R2 object's x-amz-meta-original-name currently records the mangled Waterford_20County_20Painters_logo.png; the API knows the real name.
D Business Info + Blog content plane (same key, +Business Info/Blog` scopes). Planned
Render gaps (carousel video-slide, variant:video heroes) — tracked separately (ADR-0009 hero). Carousel video-slide DONE 2026-07-12 pt5 (ADR-0009 Slice 5b) — the video renders.
E Asset reconciliation (apps/cms/scripts/asset-reconcile.mjs) — joins Wix pool → R2 → seed → deployed HTML; buckets pulled-but-unrendered / referenced-but-missing / empty-image-slots / rendered-placeholders / at-source-never-pulled. Reporting-only, gates nothing. Done 2026-07-12 pt5 — see known-issues "WCP logo-strip".
F VOD-channel video acquisition — enumerate _api/vod/public/lists/<channelId> per gallery channel → per-video access token (_api/v1/access-tokens) → download tokenized HLS → remux to mp4 (ffmpeg) → R2. The THIRD Wix media surface (see amendment below). Feeds the gallery-video bucket whose render/producer already shipped (ADR-0009 Slice 5, 2026-07-15). BLOCKED AT SOURCE 2026-07-16 pt2 — not Planned, and not startable. The VOD API 403s for the live site's own player (see the fourth amendment below). Prep facts banked: channel ids are network-only (not in markup) — 9ba179c068494c0c8d7bcbc585393c8c, 4cd95f58005b4a48a57a1e3f133e20ab; ffmpeg 8.1.2 on the box. Reopen gated on the Wix Video app's account status.
G Wix Data collections content plane — the structured-data surface (POST /wix-data/v2/items/query), NOT media and NOT the media-id join. See the fifth amendment below. DROPPED 2026-07-17 — surface is empty for FCR by construction. Cathal: the portfolio sites were not built on the Wix Data / code editor, so there are no collections to read. The prevalence question the fifth amendment gated on is answered nil from build knowledge — no scope-add or probe needed. Recorded (not deleted) so the "new CMS API!" reflex doesn't re-chase it. Reopen only if FCR onboards code-editor sites.

Amendment (2026-07-12 pt5) — listFiles is ROOT-FOLDER-scoped; the POOL needs searchFiles

The spike above (and the Slice A client as first written) used GET /site-media/v1/files. That endpoint lists a single folder — the root (media-root) by default — not the library. A real FCR site files its media into folders (WCP has Commercial Painting, Residital, Repair, logos, Misc, …), so the pool it returns is a fraction of the truth:

enumeration WCP files
listFiles (root only) 94
POST /files/search (all folders) 392

The spike's headline numbers were right by luck — WCP's hero video and all 7 carousel frames happen to sit in the root folder, so the by-id match still hit 7/7. The failure mode is silent and dangerous in the other direction: any "is this asset present at source?" question answered from listFiles will confidently report a present asset as missing (it first told us 91 of 94 library files were "never pulled", which was nonsense).

Contract: the media POOL is searchFiles (POST /site-media/v1/files/search, body {mediaTypes:[…], paging:{limit,cursor}}, cursor at pagingMetadata.cursors.next, terminate on hasNext:false). listFiles stays available for a deliberate single-folder listing. Both the video lookup and the reconciliation now use searchFiles. See known-patterns "A Wix media listFiles returns ONE FOLDER".

SUPERSEDED by the second amendment below (2026-07-13 pt3). "The POOL is searchFiles" was measured on WCP alone and is wrong on garvanbay in the opposite direction. Use mediaPool (the union). Do not re-derive this from one site.

Second amendment (2026-07-13 pt3) — NEITHER endpoint is the pool; the pool is the UNION

The amendment above corrected listFilessearchFiles on the strength of one site (WCP: listFiles 94 root-only vs searchFiles 392 — a 4× under-report). The moment a second site was brought onto the API, that contract broke the other way:

enumeration WCP garvanbay
searchFiles (all folders) 393 0
listFiles (root folder) 94 41
folders 7 0 (flat library)
mediaPool (union, deduped by id) 393 41

garvanbay returns ZERO from searchFiles — it is evidently absent from Wix's media search index (an older site; rootFolder is ignored by the endpoint, so that is not the cause). It is not an auth failure: the call 200s, the key has access, and the root listing returns 41 files.

This is the most dangerous failure a reconciliation can have. A truncated pool reports PRESENT assets as MISSING (the first amendment's lesson). An empty pool reports zero asset debt — the flattering answer, and the one nobody double-checks. Reconciling garvanbay from searchFiles would have certified it clean while telling us nothing.

Contract (supersedes the above): the pool is mediaPool({site, mediaTypes}) — the union of searchFileslistFiles(root) ∪ listFiles(each folder from GET /site-media/v1/folders), deduped by media id. It returns {files, sources, warnings} and warns loudly when the two enumerations disagree (garvanbay: "searchFiles returned 0 but the folder walk found 41 — this site is NOT in Wix's media search index"). asset-reconcile.mjs consumes it and records wixPoolSources + wixPoolWarnings in the report, so an under-reported pool can never be silently read as "no debt".

The generalisable lesson — and it is the third time this shape has bitten: a contract derived from ONE reference site is a hypothesis, not a contract. WCP alone "proved" search ⊃ list; garvanbay alone would have "proved" list ⊃ search. Only the union is true of both, and we only found that out by adding a second site. See known-patterns "A media-pool enumeration derived from ONE site is a hypothesis".

Third amendment (2026-07-15) — there is a THIRD Wix media surface: VOD channels, not in the Media Manager pool

The first two amendments corrected how to enumerate the Media Manager pool (listFilessearchFilesmediaPool union). A third surface exists that neither enumeration touches. FCR gallery pages file their per-category videos in Wix VOD "channel videos" widgets — a different Wix product. Measured on WCP: all 13 gallery videos are absent from mediaPool (mediaTypes=VIDEO, 46 files) by every id form (file id, media.video.id, posters[].id). The live player enumerates them from GET /_api/vod/public/v3-to-v2/public/lists/<channelId>?media_type=secure_video (items carry source_url: null), gets a JWT from GET /_api/v1/access-tokens, and streams tokenized HLS (repackager.wixmp.com/.../master.m3u8?token=<JWT> + .ts). The captured mp4-shaped URL 400/403s on direct fetch (token required, short-lived).

So the content plane has three Wix media surfaces, and the by-id mediaPool join (this ADR) covers only the first two:

  1. Media Manager imagesmediaPool, mediaTypes=IMAGE (Slice C).
  2. Media Manager videosmediaPool, mediaTypes=VIDEO (Slice B; the hero video).
  3. VOD channels — a separate API (_api/vod/public/...) + tokenized HLS delivery (Slice F, Planned).

The generalisable lesson, and it is the fourth of this shape: the pool-truncation amendments condition you to answer "asset not found" with another pool fix. This one is not a pool fix — the asset is in a different product. The tell is the live network trace, not a re-derivation of mediaPool: the _api/<product>/... path names the product, and source_url: null + a ?token= HLS request means streamed-not-downloaded. See known-patterns "A Wix gallery's VIDEOS are VOD-channel HLS, not Media Manager".

Fourth amendment (2026-07-16) — the third surface is DOWN at source; and "not in the pool" was measured with a join that misses its own control

The third amendment established that VOD channels are a separate Wix product the mediaPool join cannot reach, and scoped Slice F to acquire them through the VOD API. Slice F's Step-0 probe killed its own premise before any code was written.

The VOD API 403s for the live site's own player. Every _api/vod/.../lists/<channelId> and .../play/<id> call on live WCP returns 403 "Internal server error of auth middleware"; zero HLS/repackager/.ts requests fire; zero <video> elements mount. Reproduced 5 ways (headless and headed, default and real Chrome UA, consent declined and accepted, all 4 category tabs) — so it is not headless detection, not a consent gate, and not a mis-aimed probe. The widget still paints a poster + "Play Video", so the page looks video-bearing; the failure is console-only.

This contradicts the third amendment's own trace, which describes lists/ returning items (source_url: null) on 2026-07-15. Either the app broke in the intervening day or that trace's context differed. Not resolvable from our side — the reopen is an account-side question (the Wix Video app's status on the WCP account), not a pipeline task.

Scope is wider than the gallery: roof-painting (ADR-0009's fcr.about ×11 bucket) fires the same call and gets the same 403. ~24 of WCP's 27 missing videos are behind this one wall; only the hero (Media Manager, migrated by Slice B) is unaffected. So the ranked render backlog in ADR-0009 Slice 5 is not a queue of independent work — it is one blocked question with three render homes behind it.

A CDN control worth keeping. Same URL form, no API, no token: GET video.wixstatic.com/video/<id>/480p/mp4/file.mp4 returns 206 video/mp4 for the hero and 403 text/plain for the gallery videos. That is the cleanest statement of the split — Media Manager assets are directly fetchable; VOD assets are not, by design.

And a correction to how "not in the pool" was established. The claim is re-confirmed (0/13 under a much wider net than the original three id forms: every string at any depth in every pool record, the bare 32-hex tail, plus a raw substring sweep). But the original test was weaker than it read: the hero — the one video known to be in the pool — also fails the capture-id join (it is 8b4be4_a0c791484f4b492ca396ec695d175f1a in the pool, and reached R2 via its poster id, not its video id). A join that misses its positive control proves nothing on its own; the conclusion survives because the widened sweep and the CDN control agree with it, not because of the id join. The downstream casualty is known-issues' "19 are library cruft", which was 46 − 27 over disjoint sets — corrected there. See known-patterns "A set-difference is not a number until you have measured the INTERSECTION".

What Slice F still needs if the app comes back: the channel ids are not in the rendered markup — they exist only in the player's network requests, so the producer is a capture-plane network-interception probe (buildable; the capture is already a Playwright crawl). Today's values: 9ba179c068494c0c8d7bcbc585393c8c, 4cd95f58005b4a48a57a1e3f133e20ab. ffmpeg 8.1.2 is available. Re-run the playback measurement first — if the source still 403s, none of it is worth building.

Fifth amendment (2026-07-17) — a FOURTH content surface: Wix Data collections (structured content, NOT media, NOT the media-id join)

A Wix "CMS" API was pointed at the portfolio in the hope it would unblock the missing videos. It does not, and the why is the whole point of recording it: the URL path .../business-solutions/cms/ names the product ("CMS" is Wix's rebrand of the Content Manager), but the resource list is data collections — Data Items, Collection Management, Operations, External Databases (https://www.wixapis.com/wix-data/v2/...). There is no media-asset, video-file, VOD-channel, or Media-Manager endpoint anywhere in it. The 24-of-27 video wall is the third media surface (VOD, fourth amendment) and this API has no door onto it. The tell was reading the API's own resource enumeration, not its confusingly-named path — the same discipline as the third amendment (read the live trace, not the product name) arriving from the opposite direction.

What it is is the site's structured-data content plane: the rows behind dynamic pages, repeaters, member-submitted content, job boards, and Wix app collections (Stores Products, etc.). That is a genuinely new surface, distinct from Slice D's Business Info + Blog (those are the business record and the blog app; this is arbitrary user/app collections). It also speaks to project_goals' "CSR text capture as JSON" — collection content is JSON at the source, no DOM reconstruction.

But this surface breaks the ADR's central join, and that is the load-bearing caveat. Every media slice here matches the API to the capture by media id — an asset sits in a hero/section and carries an id the scrape surfaces. Collection data has no such anchor. It backs a dynamic route or a repeater, so the join is route → dataCollectionId → items and then repeater DOM → item, and nothing in the current capture surfaces which route is dynamic or which collection backs it. So folding this in is not "add another mediaTypes filter"; it is an unspecced second join model. Do not spec it on zero data.

Two locks stand before any of that is worth thinking about, and both are off our side:

  1. The key cannot see it. Querying items needs the Read Data Items (SCOPE.DC-DATA.READ) scope; discovering which collections exist needs the Collection Management read scope. The existing key is Manage Site Media + Business Info + Blog — neither Data scope is on it. Adding them is an account-side action at manage.wix.com/account/api-keys, exactly like the VOD reopen is an account-side question. Until then this surface is not measurable at all, only speculated about.
  2. The surface may be empty portfolio-wide. The Wix Data APIs require the site's code editor to be enabled (stated in the API's own introduction). FCR's portfolio is largely brochureware built in the Wix editor with no code editor — those sites have zero data collections. So the first question is not "how do we join it" but "does any FCR site use collections at all?" — a prevalence probe (List Data Collections over a sample once the scope lands), the same shape as measure-two-band-wrappers: measure before scoping.

Consequently nothing is Planned. Slice G is gated, not queued: scope-add → prevalence probe → (only if non-trivial) spec the route→collection join. Promoting it to a build on the strength of "a new API appeared" would be the one-site-hypothesis trap (second amendment) in a new costume — here with a sample size of zero.

Closed same day — the prevalence question is answered nil from build knowledge. Cathal: the FCR portfolio was not built on the Wix Data / code editor, so the collections surface is empty by construction — no scope-add and no probe can find rows that were never authored. Slice G is DROPPED, not gated. This amendment stays as a why-not record: the next session that meets the "CMS API" and reads "data collections" should stop here, not re-open a scope request. It reopens only if FCR later onboards code-editor-backed sites.

Site id — a fact of the site, not a config chore

wix-site-id is required on every call, and 1800 hand-entered ids is not a plane. It doesn't have to be: every rendered Wix page carries metaSiteId=<uuid>, so the id is recoverable from the crawl we already do. Resolution precedence in the client: WIX_SITE_ID env → sites/<site>.jsonc#wix_site_id (the durable committed fact) → derived from builds/<site>.ie/pages/*.html (the bootstrap). It is committed to the fleet descriptor because builds/ is gitignored — a fresh checkout has no crawl to re-derive from (garvanbay, whose crawl inputs live only on EC2, is exactly this case and currently has no id).

References

  • Spike: throwaway scratchpad/wix-probe.cjs (2026-07-12 pt4); WCP metaSiteId dd5a7db4-38c2-4360-82f0-d4b1e397d3b7.
  • Wix docs: API Keys (https://manage.wix.com/account/api-keys), Media Manager listFiles (https://dev.wix.com/docs/rest/assets/media/media-manager/files/list-files).
  • ADR-0009 §4 (two-plane ingestion), ADR-0005 §6 (provenance in-data), ADR-0008 (account sharding). Memory: project_goals (need CSR text capture as JSON).