Lock Paid Experiment Copy with Sanity Releases, Optimizely Graph, and HubSpot Campaign IDs
The bug shows up after launch A paid landing page does not go stale all at once. It drifts. On Monday, the Google Ads headline says "SOC 2-ready analytics for
The bug shows up after launch
A paid landing page does not go stale all at once. It drifts. On Monday, the Google Ads headline says “SOC 2-ready analytics for seed-stage fintech teams.” On Tuesday, someone fixes the Sanity hero copy for the organic product page. By Thursday, variant B in Optimizely is pulling a fresher CMS value than variant A. HubSpot still attributes traffic to the same campaign, but the test has stopped testing what the team approved.
I have seen this happen on landing pages with only 2 variants and 4 fields. The common trigger is boring: headline, subhead, CTA, proof point. Small edits land in the CMS after launch, and the experiment layer reads whatever is current. The paid team thinks it is running a clean test. The page is quietly swapping the lab sample.
The fix is to treat campaign copy like application state. Sanity owns the approval workflow. Optimizely Graph owns deterministic delivery. HubSpot owns campaign attribution and reporting. The app between them should lock a visitor-facing experiment to a specific approved release, then write the result back against the HubSpot campaign GUID that paid, email, and CRM teams already recognize.
Sanity releases are the approval boundary
Sanity’s Content Releases guide was last updated on August 25, 2026, and it now describes a workflow that marketing ops people can actually use: group changes across multiple documents, preview them, validate them, schedule them, then publish them together. It requires Sanity Studio v3.77.0 or later. The JavaScript client docs, updated August 4, 2026, put release operations behind client.releases and require @sanity/client 7.8.0 or later for helpers such as release creation.
That matters because paid pages are rarely one document. A real campaign page might pull landingPage.pricingTable, testimonialBlock, legalDisclaimer, heroCopy, and formConfig. If only 4 of those 5 documents move together, the page can pass visual QA and still ship the wrong promise. Sanity’s documented release limit is 1,000 documents and 100 MB of combined JSON, which is plenty for a campaign family with 12 markets, 3 personas, and 2 languages.
I would make the Sanity release the source of approval, not the live published document. A release called 2026-Q4-PMM-ACME-FINTECH-LP gets a hubspotCampaignGuid, a launchWindow, and a small array of allowed experiment variants. When the release is approved, the integration snapshots the copy into an experiment manifest. After that point, day-to-day CMS edits can keep moving without rewriting history for the paid test.
Optimizely Graph should serve the locked shape
Optimizely Graph is a GraphQL delivery service for content stored in Optimizely products, as Optimizely says in its Graph overview. It is not Sanity’s Content Lake. That distinction is worth saying out loud. The app pattern here is a bridge: use Sanity releases as the editorial approval system, then sync the approved campaign payload into Optimizely CMS SaaS content types or a small Optimizely-backed campaign content model that Graph can query.
Once the payload sits in Optimizely’s content graph, Graph gives the app the part marketing teams need most: exact field selection and filtering. The content retrieval docs describe GraphQL queries for variations, raw JSON, filters, and fallback cache behavior. The variation docs say Graph does not include variations by default. You must ask for all variations with variation: { include: ALL }, a selected subset with include: SOME, or original content with include: NONE.
That default is a gift. It keeps experimental copy out of normal page reads unless the application opts in. For a paid landing page, I would query by campaignGuid, releaseId, variantKey, and locale. Those four fields are boring enough to survive a 9-month test backlog, and they are specific enough to stop a generic CMS edit from leaking into an active experiment.
A campaign record can stay small. In one B2B SaaS setup, I would start with this shape: campaignGuid, sanityReleaseId, optimizelyExperimentKey, variantKey, headline, subhead, primaryCta, proofPoint, formGuid, approvedAt, copyHash. The hash is not fancy. SHA-256 over the approved JSON is enough to detect that variant B on October 12 is still the same variant B legal approved on September 29.
HubSpot is the reconciliation key
HubSpot’s Campaigns API now uses the /marketing/campaigns/2026-03 path. A created campaign returns a campaignGuid, formatted as a UUID. HubSpot’s docs show edb9b6c3-d2e2-4ca8-8396-832262aed0d4 as an example, and the retrieve endpoint accepts that GUID at /marketing/campaigns/2026-03/{campaignGuid}. Metrics can be requested with startDate and endDate on the retrieve endpoint, according to the endpoint page last modified April 13, 2026.
That GUID belongs in the content model. Do not depend on campaign names. Names change when finance wants Q4 Demand Gen renamed to Q4 Pipeline Acceleration, and HubSpot also separates campaign names from UTM campaign values. HubSpot’s own UTM article, updated December 19, 2025, says a new campaign gets a unique Campaign UTM value and that traffic with the matching UTM value is attributed to the campaign.
The form layer needs the same discipline. HubSpot’s forms docs, updated August 5, 2026, call the form GUID a 128-bit identifier and show where to find it in data-form-id in the embed code. Store formGuid beside campaignGuid in the locked manifest. If a growth marketer swaps the form from Demo Request to Pricing Inquiry on the HubSpot side, the landing page should know whether that was approved for this experiment or a stray edit.
The pattern I use
The clean version has 5 moving parts. Sanity Studio holds campaign copy in release versions. A sync worker reads the approved release through @sanity/client after approval or schedule. The worker writes an immutable campaign content record into Optimizely CMS SaaS. Optimizely Graph serves that record to the Next.js, Remix, or Astro page. HubSpot receives the campaign association and later becomes the join point for lead, deal, and revenue reporting.
I would run the worker as a queue job, not inside the editor’s browser. A tiny Node 22 service on Vercel Cron, GitHub Actions, or AWS Lambda is enough for most teams. The job receives sanityReleaseId, hubspotCampaignGuid, and experimentKey. It fetches the Sanity release version, validates required fields, computes copyHash, writes the Optimizely content record, and posts a comment or status back to the Sanity release. If validation fails, the release can stay unpublished. A missing primaryCta should block a $40,000 LinkedIn flight.
The page runtime stays thinner. On request, read utm_campaign, hsCtaTracking, or the HubSpot campaign GUID from your tracking URL convention. Ask Optimizely for the visitor’s variation decision. Then query Optimizely Graph for the exact campaign and variant. If Graph returns zero records, serve a plain approved control, log experiment_content_miss, and stop the Optimizely impression event from firing. A broken payload should not count as a losing variant.
Here is the core GraphQL idea, trimmed to the fields a landing page needs:
query PaidLandingPage($campaignGuid: String!, $variantKey: String!, $locale: String!) {
CampaignLandingPage(
where: {
CampaignGuid: { eq: $campaignGuid }
VariantKey: { eq: $variantKey }
Locale: { eq: $locale }
}
variation: { include: SOME, value: [$variantKey], includeOriginal: true }
) {
items {
Headline
Subhead
PrimaryCta
ProofPoint
FormGuid
CopyHash
SanityReleaseId
}
}
}
The field names are examples, but the constraints are real. Optimizely’s filter docs support exact matching with eq, and its filter-content guide says fields used in where clauses must be configured as filterable. Make CampaignGuid, VariantKey, and Locale filterable. Leave long rich text as output only, since nobody needs to search a CTA paragraph during a page render.
Where teams usually cut corners
The first shortcut is putting only the utm_campaign value in the page and calling it done. That works until HubSpot keeps an old UTM as a secondary value, which its UTM docs allow for reporting continuity. Keep the HubSpot campaignGuid in the manifest and treat the UTM as a traffic label. The GUID is the object key.
The second shortcut is reading live Sanity content at render time. That feels clean during build week. It becomes messy once a content editor fixes organic SEO copy 11 days into a paid test. Sanity releases give you the approved version. Use that version to generate the locked Optimizely record, then let the live CMS continue its separate life.
The third shortcut is reporting only Optimizely conversions. Optimizely can tell you which variation won the page event. HubSpot can tell you whether the campaign created contacts, meetings, deals, and revenue. The reconciliation job should write Optimizely experiment keys and variant keys into HubSpot asset notes, a custom object, or your warehouse table keyed by campaignGuid. For a founder spending $12,000 a month on search and retargeting, the useful question is not “which headline got clicks?” It is “which approved promise created qualified pipeline by February 6?”
A concrete operating model
For a 6-person marketing team, I would keep the governance plain. The lifecycle has Draft, Approved Release, Synced to Graph, Live Experiment, Reconciled. Sanity owns the first 2 states. The sync worker owns the middle 1. Optimizely owns exposure and variant assignment. HubSpot owns the financial view.
Use names humans can debug at 5:30 p.m. on a Friday: hubspotCampaignGuid, sanityReleaseId, optimizelyExperimentKey, variantKey, copyHash. Put all 5 values into page logs and analytics events. In Datadog or BigQuery, one row should show that visitor anon_8f21 saw variant_b, from Sanity release rel_2026_q4_fintech, with copy hash 9c4a..., tied to HubSpot campaign edb9b6c3-d2e2-4ca8-8396-832262aed0d4.
That is the whole advantage of this app pattern. It gives marketing operators the same repeatability engineers expect from deployments. Copy gets approved once, served by key, measured by campaign, and reconciled after the test. Nobody has to freeze the CMS. Nobody has to trust screenshots in a Notion page from 3 weeks ago. The experiment can move fast because the copy cannot wander.
Newsletter
Get growth playbooks in your inbox.
Practical SEO, PPC, automation, and web strategy from the Micromarketing team. No fluff, unsubscribe anytime.