Migrating from UNRVLD.ODP.VisitorGroups to the official Optimizely.Cms.Odp package
Nov 09, 2026
TLDR; Optimizely now ships its own ODP package for CMS, Optimizely.Cms.Odp. It replaces the Real Time Segment criterion in the community package UNRVLD.ODP.VisitorGroups and adds a timeout and circuit breaker. It does not replace the Engagement Rank, Order Likelihood, Winback Zone, Observation or Customer Property criteria, which have to be rebuilt as real-time audiences in ODP first. I've put together some scheduled jobs that swap the criteria in place, so none of your content needs editing. The repo also includes instructions and commands for Claude Code. The rest of this post explains the differences and how to migrate.
For the last few years, UNRVLD.ODP.VisitorGroups has been the way to personalise Optimizely CMS content using data from Optimizely Data Platform (ODP). Andrew Markham built it and I've been a collaborator on it.
However, Optimizely has now released an official package, Optimizely.Cms.Odp. Version 1.x is for CMS 12 and 2.x is for CMS 13. That raised a fair question on GitHub in issue #36: should we switch and if so, how?
This post covers:
- whether the official package duplicates the community one
- what the capability differences are
- a set of scheduled jobs and a step-by-step process to migrate, with or without Claude Code
Does the official package replace UNRVLD.ODP.VisitorGroups?
Partly. The official package adds a single criterion: Data Platform > Audience Membership. It checks whether the current visitor is in an ODP real-time audience, the same thing UNRVLD's "Is In Segment" criterion does.
UNRVLD has six other criteria that query a visitor's ODP profile directly. None of them has an equivalent in the official package. The good news is that ODP can express all of them as a real-time audience, so the approach is:
- build the rule as a real-time audience in ODP
- use Audience Membership to check it
It's arguably a better model too, as the logic lives in ODP rather than in CMS.
Comparing the two packages
I compared UNRVLD 2.4 with Optimizely.Cms.Odp 1.2.0 and 2.0.0:
| UNRVLD.ODP.VisitorGroups 2.4 | Optimizely.Cms.Odp 1.2 / 2.0 | |
|---|---|---|
| Real-time segment criterion | "Is In Segment" | "Audience Membership", which also supports "Not a member of" |
| Engagement Rank order Likelihood, Winback Zone | Yes | No: build a real-time audience in ODP |
| Observations (total revenue order count, average order revenue) | Yes | No: build a real-time audience in ODP |
| Customer properties (text / number) | Yes | No: build a real-time audience in ODP |
| Multiple ODP instances | Yes | Yes |
| Audience size shown in the editor | Yes | Yes |
| Visitor identifier | One cookie, with a configurable ODP field | Several identifiers, read from a cookie then the query string |
| API timeout | None | ApiTimeoutSeconds (default 10) |
| Circuit breaker | No | Yes, per instance |
| Membership cache (default) | 1 second | 60 seconds |
| ODP tracking snippet injection | No | Optional |
| Config validated at start-up | No | Yes |
| CMS support | 12 and 13 from one package | 1.2.x for CMS 12, 2.x for CMS 13.1+ |
| Support | Community (MIT licence) | Optimizely |
The API timeout and circuit breaker rows are the ones I’d pay most attention to. UNRVLD makes its ODP calls synchronously with no timeout. When ODP is slow, page requests can back up behind it, as discussed in issue #35. The official package caps each call at 10 seconds by default. After repeated failures it stops calling ODP for a while and falls back to “not a member”.
The configuration reference is in the official docs.
Why migrate in place?
Content doesn't store the criteria. Personalised content areas, personalised rich text and access rights all reference a visitor group by its ID.
So rather than creating new visitor groups and re-pointing content at them, the jobs edit each existing group. They swap the old criterion for the new one and leave the ID alone. This means:
- no content is edited, re-published or put through approval
- visitor group statistics are kept
- the change can be rolled back
The migration code
The sample code is on GitHub: odp-visitor-group-migration. It contains three scheduled jobs:
| Job | What it does |
|---|---|
| ODP migration 1 - Report (read only) | Lists every visitor group that uses UNRVLD criteria and what will happen to each criterion, then scans content for where those groups are used. It writes two CSV files to an admin-only folder in the media library. |
| ODP migration 2 - Swap criteria | Backs up each Real Time Segment criterion, then replaces it in place with Audience Membership. It is safe to re-run. |
| ODP migration 3 - Roll back swap | Puts the original criteria back from the backups. It leaves alone anything an editor has changed since the swap. |
The jobs should run on both CMS 12 and CMS 13.
The core of the swap is small. The trick is knowing what each package stores:
return new VisitorGroupCriterion
{
Id = Guid.NewGuid(),
TypeName = OfficialCriterionTypeName,
Model = new OdpAudienceCriterionModel
{
Instance = mapping.PublicApiKey, // the official criterion stores the instance's PublicApiKey, not its name
Condition = "MemberOf",
AudienceId = mapping.AudienceId // bare ODP audience name, with no "[Endpoint] - " prefix
},
Enabled = old.Enabled,
Required = old.Required,
Points = old.Points,
Notes = old.Notes,
NamingContainer = old.NamingContainer
};
The details that took some digging:
- UNRVLD stores the ODP audience name. When more than one endpoint is configured, the name is prefixed with the endpoint, e.g.
[US] - my_segment. An unprefixed value means the first endpoint. - The official package stores the bare audience name in
AudienceId. It stores the instance's PublicApiKey inInstance, not the instance name. - The two packages name their instances separately. The job maps them automatically when it can. When it can't, use an
OdpMigration:InstanceMapsetting. It never guesses for a value that names its endpoint. - Anything else is reported, not converted. That covers the six criteria with no equivalent and any criterion saved by UNRVLD 1.x (the type names changed in 2.0).
Migration steps
Visitor groups live in the database, so repeat these steps in each environment. Alternatively, migrate production and copy the database down.
-
Back up. Take a database backup or on DXP an export or point-in-time snapshot. Do a dry run on a copy of production data first if you can.
-
Install Optimizely.Cms.Odp alongside UNRVLD. Use 1.2.x on CMS 12 or 2.x on CMS 13.
- Configure
Optimizely:Odp:Instanceswith both the private and public API keys. UNRVLD only needed the private key. - If the ODP snippet is already on your site, leave the official package's
Trackingsetting off or you'll load the snippet twice. - NuGet package / configuration docs
- Configure
-
Add the migration code and deploy.
- Copy the files in and call
services.AddOdpVisitorGroupMigration(_configuration). - Make sure a media type accepts
.csvfiles. - If you use Claude Code,
/integratedoes this step for you (see below).
- Copy the files in and call
-
Run "ODP migration 1 - Report". Open the CSVs in For All Sites > ODP migration reports. The criteria report shows what will be swapped and what needs manual work. The usage report shows where each group is used, which becomes your test list.
-
Deal with the manual items. For each unsupported criterion:
- recreate the rule as a real-time audience in ODP
- replace the criterion with Audience Membership in the CMS audience editor
-
Run "ODP migration 2 - Swap criteria", then run the report again. It should list only the groups you're handling by hand or nothing at all.
-
Test.
- Open a few migrated audiences in the editor and check the right instance and audience are selected.
- Browse the pages from the usage report as a visitor known to be in and not in, the ODP audience. Check the right content appears.
- Watch the logs for ODP warnings.
- If something is wrong, run "ODP migration 3 - Roll back swap".
-
Uninstall UNRVLD. When the report is clean and testing has passed:
- remove the migration code
- remove
AddODPVisitorGroups() - remove the
EPiServer:OdpVisitorGroupOptionsconfig - remove the UNRVLD package
- deploy
Optionally delete the backup store and the report folder afterwards.
Using Claude Code?
The repo includes a CLAUDE.md and two Claude Code commands to speed up steps 3 to 5. Start Claude Code in the sample repo and give it access to your site:
git clone https://github.com/<you>/odp-visitor-group-migration
cd odp-visitor-group-migration
claude --add-dir ../my-optimizely-site
Starting in the sample repo matters, as that's where Claude Code picks up CLAUDE.md and the commands from. Then:
/integrateworks out your CMS version and checks the prerequisites (both packages, ODP instances, a media type for.csv). It then proposes the changes and asks before copying the files in, registering the services and getting the site building.- Deploy and run the Report job, download the two CSVs, then run
/review-report <criteria.csv> <usage.csv>. It turns them into:- a to-do list for the criteria needing manual work, with suggested ODP real-time audiences for the unsupported ones
- a test plan picked from the usage report
- When you're done, ask Claude to remove the migration code and the UNRVLD package.
CLAUDE.md lists what each package stores and the rules the migration depends on: edit in place, back up first, never guess an instance, never log keys. That stops the AI "fixing" things it shouldn't. It's also told not to run jobs or touch a live site without your say-so, so the Swap and Rollback jobs are still yours to run. As always, review the changes before you commit them.
Gotchas worth knowing
- Membership is cached for longer. The official package caches membership for 60 seconds by default, where UNRVLD effectively cached for 1 second. A visitor who joins an audience may not see personalised content for up to a minute. Use
MembershipCacheTimeoutSecondsto tune it, alongsideCacheSizeLimit(default 1,024 entries, which is low for a busy site). - Real-time audiences only look at recent data. Optimizely's docs say real-time audiences consider the last 28 days of data. Check that a rebuilt Observation rule, such as lifetime total revenue, still behaves the way you expect.
- Use the
Instancesconfig format. Multiple ODP instances are supported, but they must be configured underOptimizely:Odp:Instances. The README inside the 1.2.0 package shows a flat, single-key example that the package won't accept, as it validates the config at start-up. - Tracking is one instance per site. If you use the official package's tracking snippet injection, each CMS site can only belong to one instance and every instance needs a unique
PublicApiKey. - Two "Data platform" categories during the migration. UNRVLD uses "Data platform" and the official package uses "Data Platform", so editors will see both until UNRVLD is removed.
- CMS 13 audiences are opt-in. You need
EPiServer.CMS.UI.VisitorGroupswithAddVisitorGroupsMvc()andAddVisitorGroupsUI(). If you're already using visitor groups on CMS 13, you have these. - Headless. Neither package has anything specific for headless. Both read the
vuidfrom a cookie or the query string, so make sure it reaches the CMS.
Disclaimer
This code is provided as-is, as an example to implementors, under the MIT licence. I haven't been able to test it against every CMS 12 and 13 minor version, so run it somewhere safe first and keep that database backup handy.
Credits and feedback
A huge thank you to Andrew Markham for building and maintaining UNRVLD.ODP.VisitorGroups and for being happy for me to post this. The community package did the job well for a long time and it still supports CMS 13 if you're not ready to move.
Please leave feedback in the comments below or raise issues on the sample repo.
Happy migrating.
Useful links
- odp-visitor-group-migration (sample code)
- UNRVLD.ODP.VisitorGroups on GitHub
- Issue #36: should we switch to the Optimizely package?
- Optimizely.Cms.Odp on the Optimizely NuGet feed
- Configure Real-Time Audiences to personalize CMS 12
This blog represents personal views and experiences. For official Optimizely documentation, visit docs.optimizely.com.