For your platform and security teams
We are rolling out a new version of our tracking methodology, the Conversio Tag. This direction moved the complexities of our tracking out of your Tag Management container and hosts it externally on industry standard infrastructure. This allows our tracking solution to be as light as possible (only 6KB!) to be efficiently delivered to end-user devices and to maintain the highest levels of security, data compliance and data integrity.
It also allows us to deliverer enhancements and introduce updates that offer richer data and better insight, alongside also working natively with the most up to data consent management requirements.
All of this is done with with complete transparency and with no risk to your data and the privacy of your customers. This solution has been in beta for a couple of months now, live to real visitors, and is ready to be rolled out to all of our clients.
This document is written to clearly set out what the Conversio Tag does on your site, what data moves where, and what it cannot do. It is also aimed at answering most of the questions you may have ahead of installation.
The tag is a single JavaScript file loaded through your Google Tag Manager (or other tag management provider) container. It reads experiment and event information your container pushes to the dataLayer, de-duplicates it, and reports it to your own Google Analytics 4 property. It will look similar to this:
<script src="https://tag.conversio.dev/t/cvo_sG0XUKi6G-ZXmRUTehJqRLq2.js" async></script>
One thing is important to be clear about upfront with the Conversio Tag, because it isn't like other third-party measurement scripts: the tag makes no network requests of its own. It contains no fetch, no XMLHttpRequest, no navigator.sendBeacon and no image beacons. It writes to the dataLayer and calls the gtag function your page already loads. There is no channel by which visitor data reaches Conversio from the browser.
The script fetch is the only one involving Conversio-operated infrastructure, and it carries no measurement data.
| Flow | Destination | Contents |
|---|---|---|
| Script delivery | tag.conversio.dev, a Cloudflare Worker | An ordinary HTTPS GET for a JavaScript file. Carries whatever any asset request carries: source IP, user agent, referring page. No measurement data, no identifiers. |
| Measurement | Your GA4 property | Experiment and event names, and a random visitor identifier. Sent by your page's own gtag, pinned to the single measurement ID you nominate. |
| Page diagnostics | Your dataLayer only | Core Web Vitals for the page load, available to your own container. Not transmitted anywhere by the tag. |
| Anything to Conversio | None | No such flow exists. The tag issues no requests. |
Yes, and you can verify it for yourselves. The file is un-minified and readable, with original identifiers and one statement per line. It is generated from a source file by a build step that only strips comments, and the build is reproducible: the bytes we serve you can be regenerated from a specific commit and compared.
Ask us for the commit hash of the version you are served and we will walk the comparison through with you. It is a byte-for-byte match once the values we substitute for you are put back to the placeholders the published source carries. We would rather do that alongside you than hand over a recipe, because which values are substituted changes from one version to the next, and a recipe written against an older version would report a difference that is not really there.
No, and the reason is structural rather than an oversight. Values specific to you, your measurement ID among them, are substituted into the file when it is served, so the bytes differ from one client to the next and no single integrity hash could cover them all. Publishing one would break your tag the moment your configuration changed.
What we offer instead is stronger for the question SRI is usually asked to answer. An integrity hash tells you a file has not changed since you reviewed it. Version pinning tells you it cannot change: the file we serve you stays exactly as it is until we move your key, on a date agreed with you, and never as a side effect of us releasing anything. That is set out under How Updates are Tested and Communicated below, and the values the tag publishes on every page load let you confirm which version and which deployment date you are on at any time, without asking us.
If an integrity hash is a firm requirement rather than a preference, for instance because the tag would sit on a page in scope for PCI DSS, please just raise this with us. It is by no mean a blocker and the right solution may simply be to keep the tag off those particular pages.
One host in script-src:
script-src https://tag.conversio.dev
Nothing else. The tag loads no fonts, stylesheets, images or further scripts, and opens no connections, so no other directive is affected. If your policy is nonce-based, the tag inherits the nonce your container applies to its injected scripts.
No cookies. Two kinds of browser storage, and nothing at all is written before your consent signal arrives.
Session storage is cleared when the tab closes. The single local storage entry is the only value that persists.
| Key | Store | Purpose |
|---|---|---|
conversio_id | local (consent gated) | A random identifier, so one visitor's page views can be related to each other inside your GA4 property. Generated only after consent. Contains nothing derived from the visitor or their device. |
conversioEmissionEnabled clientEmissionEnabled | session | Whether your consent signal has been received, per reporting stream. |
conversioExperienceMap conversioExperienceFired conversioExperienceList | session | Which experiments this visitor has seen in this session, so a repeat is reported once rather than on every page. |
conversioEventBuffer conversioEventList | session | Events observed before consent, held so they can be reported if consent is given, and discarded if it is not. |
conversioVitalsPending | session | Page performance measurements awaiting the consent decision. |
client* equivalents | session | The same set again for your own reporting stream, which shares no storage with ours. |
Reporting is off by default and the tag never turns it on by itself. It stays off unless the Conversio Session Initiator tag, set to fire based on your own consent trigger, signals otherwise:
(window.conversioConsentQueue = window.conversioConsentQueue || []).push('granted');
A withdrawing is as easy as running the same call with 'denied'. This, therefore, can be set within the push object dynamically using a GTM variable. The signal is a queue rather than a function call so load-order cannot disrupt it; if it is pushed before the tag has loaded it waits, if it gets pushed afterwards it applies immediately. Both orders behave identically, which is important to maintain a consistent behaviour despite the speed and configuration of your consent platform.
Anything the tag does not recognise is ignored rather than treated as granted. An error in the signal results in no reporting, never in reporting the visitor did not agree to.
Every failure path returns a valid, inert JavaScript file. Your pages continue to work and your console stays clean and error-free; measurement simply stops until the problem is resolved. That covers our storage being unavailable, a configuration record being unreadable, the file being missing, and the request being rate limited.
This is fully tested. A fourteen-case fault matrix is run against a staging deployment before a release, and every case asserts a JavaScript content type, this ensures the service never returns a HTML error page into a <script> tag.
Immediately, from our side, with no change to your container and no deployment. Your key is deactivated and the tag stops being served, with your script tag left harmlessly in place. Propagation is a matter of seconds, plus up to five minutes for any copy already cached in a visitor's browser.
You can of course also remove the tag from your GTM container at any time.
Delivery runs on Cloudflare Workers, executing at Cloudflare's edge. Your configuration, which is your specific version and your measurement ID, is held in Cloudflare Workers KV. Cloudflare is the only subprocessor involved in delivering the tag.
Our application logging records nothing on the normal path. All fourteen recorded conditions are faults, such as an unrecognised key, a deactivated key, or a rate limit being hit, and the key is truncated in every one of them so that no complete credential is written to a log. A visitor being served the tag normally produces no application log entry. We cover this in more detail in the next section What Conversio logs.
Retention periods, and our data processing agreement are contractual and covered by our overarching Terms & Conditions. They are not set out in this document. Ask us for them directly; this pack covers what the software does, not what we have committed to contractually.
We would rather you verified this than accepted it. Each of these can be run in your browser's console on your own site, in an ordinary window.
conversio or client key.tag.conversio.dev. You should see exactly one request, for the script itself, and never a second.google-analytics.com carrying your own measurement ID, and confirm no request goes to any Conversio host other than the single script fetch.You can also validate the tag is successfully loading, as well as get details on the current version and its release credentials by running the command window.conversioSettings in the browser console.
The tag sends us none of your data. It makes no network requests to Conversio databases at all. The runtime tag contains no fetch, no XMLHttpRequest, no navigator.sendBeacon and no image beacon.
The only thing that reaches us is the browser's request for the script file itself, exactly as with any externally hosted script. We record nothing about that request unless it fails, and when it does fail we record a reason code and which client it concerned, never anything about the person browsing.
Everything it produces is handed to the client's own dataLayer and their own Google Analytics property.
Measurements, experiment data and Core Web Vitals are pushed to window.dataLayer and sent to the client's own GA4 property through the client's own gtag instance. That traffic goes from the visitor's browser to Google, under the client's own tag configuration and consent framework.
The tag has the ability to send failure logs to Cloudflare for stability monitoring on our end.
A request that succeeds produces no log line from our code at all. Every one of the log statements in the Worker sits on an error path, and they exist so that a client's tracking breaking is something we find out about rather than something they discover later from missing data.
Beyond these failure logs nothing else is logged in Cloudflare.
This is the complete list of details that are captured in our logs. No client performance data or user behaviour data is captured as part of these logs:
| Field | What it is | Example |
|---|---|---|
event | Always the same marker, so our lines can be filtered from platform noise | conversio_loader |
reason | Which failure this was, from the fixed list below | asset_missing |
key | The first 12 characters of the client's key, so a problem can be traced to a client | cvo_7Hq2mNx9 |
client | The client's own company name, as we hold it in their configuration | Acme Co |
version | Which version of the tag bundle was involved | 2.6.4 |
hostname | The hostname only of the site the request came from | staging.someothersite.com |
method | The HTTP method, when it was one we do not serve | POST |
These logs capture no IP address, no visitor identifier, no cookie, no user agent, no location, no page URL, no referring path.
Two of these parameters we capture are also deliberately truncated:
key is cut to 12 characters. The full key is the credential that authorises serving the tag, so a log holding it in full would be a log that could be used to impersonate a client. Twelve characters identify which client a problem belongs to without being usable as the credential.hostname discards the path. A request referred from https://staging.someothersite.com/checkout is logged as staging.someothersite.com. The hostname is the only part we require, and the path is a part that could contain information describing a user's session.Every line below is a real example of the output from the Worker failure logs.
{"event":"conversio_loader","reason":"unknown_key","key":"cvo_7Hq2mNx9"}
{"event":"conversio_loader","reason":"record_malformed","key":"cvo_7Hq2mNx9"}
{"event":"conversio_loader","reason":"revoked_key","key":"cvo_7Hq2mNx9","client":"Acme Co"}
{"event":"conversio_loader","reason":"domain_mismatch","key":"cvo_7Hq2mNx9","client":"Acme Co","hostname":"staging.someothersite.com"}
{"event":"conversio_loader","reason":"method_not_allowed","method":"POST"}
| Code | What happened |
|---|---|
unknown_key | A request carrying a key that is not in our records |
record_malformed | A configuration record that is not readable as a client record |
revoked_key | A key we have withdrawn, still being requested by a site |
domain_mismatch | A domain-locked key requested from a site not on its allow-list |
method_not_allowed | Something other than a GET or HEAD against a tag URL |
{"event":"conversio_loader","reason":"version_invalid","key":"cvo_7Hq2mNx9","client":"Acme Co","version":"latest"}
{"event":"conversio_loader","reason":"asset_missing","key":"cvo_7Hq2mNx9","version":"9.9"}
{"event":"conversio_loader","reason":"asset_error","key":"cvo_7Hq2mNx9","version":"2.6.4"}
{"event":"conversio_loader","reason":"asset_unreadable","key":"cvo_7Hq2mNx9","version":"2.6.4"}
{"event":"conversio_loader","reason":"tracking_id_rejected","key":"cvo_7Hq2mNx9"}
{"event":"conversio_loader","reason":"deployed_at_rejected","key":"cvo_7Hq2mNx9"}
| Code | What happened |
|---|---|
version_invalid | A record naming a version that is not version-shaped |
asset_missing | A client pinned to a bundle version nobody deployed |
asset_error | The bundle store threw while being read |
asset_unreadable | The bundle was found but could not be read to the end |
tracking_id_rejected | A client's GA property ID failed our safety check and was dropped rather than injected |
deployed_at_rejected | A client's deployment date was not a valid ISO date and was dropped |
{"event":"conversio_loader","reason":"rate_limited","key":"cvo_7Hq2mNx9"}
{"event":"conversio_loader","reason":"rate_limiter_error","key":"cvo_7Hq2mNx9"}
{"event":"conversio_loader","reason":"key_lookup_error","key":"cvo_7Hq2mNx9"}
{"event":"conversio_loader","reason":"serve_error","key":"cvo_7Hq2mNx9"}
| Code | What happened |
|---|---|
rate_limited | One key exceeded its per-location request cap |
rate_limiter_error | The rate limiter was unavailable. We fail open, so the tag keeps working |
key_lookup_error | Our configuration store was unreachable, or held invalid data |
serve_error | A last-resort catch-all, so an unexpected fault cannot reach a client's page as an error |
These lines are written to Cloudflare Workers Logs, retained for 7 days and then automatically deleted. We hold no other copy and export them nowhere.
Separately, our configuration store holds one record per client: their name, which tag version they are on, their GA property ID, their allowed domains and the date they were moved to their current version. It holds no visitor data.
No update reaches your site automatically from a deployment to our Cloudflare instance. Publishing a new version makes it available without moving a client on to it. Your site changes only when we move your key to it, as a separate, deliberate step we will clearly communicate to you first.
Each stage has to pass before the next begins. The first three change nothing on your site; only the last one does.
Every proposed change runs the full test suite automatically before it can be accepted, and again when it is. Roughly 7,900 behavioural checks across 15 suites, covering consent handling, event shape, deduplication, the GA4 payload and each documented failure mode.
Two things about how the testing suite runs are important to highlight:
It tests the file your visitors actually download. Each version's suite runs twice: once against the readable source we edit, and once against the exact bytes served from our hostname. Testing a copy and shipping a different file doesn't happen.
A separate check proves those two are in step. If the served file ever fell behind its source, the bytes reaching your visitors would no longer be the bytes that passed the suite. That check fails the build rather than warning, so a mismatch cannot be merged.
Older versions keep their own suites and are kept passing for as long as any client is still on them, so a fix for one client cannot impact the version another is pinned to.
None of this depends on anyone remembering to run it. It runs on every change and every merge, and a failure blocks the change rather than producing a note that can be missed.
Changes are exercised on a second, fully separate deployment before they go near the hostname your site calls. It runs on its own address, with its own limits, deliberately isolated so that nothing done there can touch a live client.
Staging is seeded with records that reproduce each way the service can fail. These are all relevant, real-world scenarios and are cases we can trigger on demand and re-check after any change:
| Case | What it reproduces |
|---|---|
| Normal request | The expected path: the script is served, correctly configured |
| Revoked client | A client whose access has been withdrawn |
| Domain-locked key | A key used from a site it was not issued for |
| Missing version | A client pointed at a version that was never published |
| No version, or an invalid one | A record that lost or corrupted its version |
| Corrupted record | A record that is no longer readable at all |
| Unsafe configuration value | A setting that would be unsafe to place into the script |
Every one of them has to answer with a JavaScript response, not an error. Nothing our service depends on can put an error page into the <script> tag on your site. A failure on our side serves a harmless empty script, so the worst case is that tracking pauses, not that your page breaks.
Your key is pinned to one specific version. Publishing a new version makes it available to be moved to; it does not move anybody, including you. This is the single most important thing about the rollout model, because it means a release that turns out to be wrong cannot reach every client at once. It cannot reach you at all until someone moves your key.
Moving you is a separate, deliberate step that names your key alone. One client at a time.
If something is wrong, rolling you back is the same operation with the previous version number, and it takes effect as soon as the cached copy expires, within minutes. Nothing needs to change on your side to roll back, and nothing needs to change on your side to be moved in the first place: the one line in your tag manager stays exactly as it is, permanently.
The same mechanism is how your access ends cleanly if we ever stop working together. Your key stops being served, your tag goes quiet, and there is nothing for you to find and remove.
| When | What you get |
|---|---|
| At least 5 working days before | What is changing and why, the version number, and the date we propose moving you |
| On the day | Confirmation that your site has been moved, with the date recorded against your key |
| If you ask us to wait | You stay on your current version. Nothing moves without your agreement |
| Urgent fixes | A security or data-correctness fix may need to move faster. We tell you before where we can, and within one working day after where we cannot, with what changed and why it could not wait |
You can confirm all of this from your own side. From version 2.6.4 the tag publishes three values on every page load, readable by your own tag manager or analytics:
window.conversioSettings.version // which version is running
window.conversioSettings.versionReleased // when that version was cut
window.conversioSettings.deployedAt // when YOUR site was moved to it
The last one is the useful one. It is set automatically when your key is moved and is specific to you, so it answers "when did our tag last change?" without asking us. Re-confirming a version you already run does not move the date, so it only ever changes when your site genuinely did.