diff --git a/integrationExamples/realTimeData/datamageRtdProvider_example.html b/integrationExamples/realTimeData/datamageRtdProvider_example.html
new file mode 100644
index 00000000000..6402f37a397
--- /dev/null
+++ b/integrationExamples/realTimeData/datamageRtdProvider_example.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+ OpsMage Prebid Test Page
+
+
+
+
+
+
+
+
+
+
+ OpsMage Prebid Test Page
+
+ The tech world is currently buzzing over the highly anticipated market debut of fakeDSP, a trailblazing
+ startup poised to redefine the landscape of digital signal processing. Leveraging proprietary neuromorphic
+ algorithms and quantum-ready architecture, fakeDSP promises to accelerate real-time data synthesis by speeds
+ previously thought impossible. With early analysts calling it a definitive disruptor in both the
+ telecommunications and audio-engineering sectors, the company’s entrance signifies a major leap forward in how
+ complex signals are analyzed and reconstructed in the AI era.
+
+
+
\ No newline at end of file
diff --git a/modules/datamageRrdProvider.md b/modules/datamageRrdProvider.md
new file mode 100644
index 00000000000..4cb705824db
--- /dev/null
+++ b/modules/datamageRrdProvider.md
@@ -0,0 +1,86 @@
+# DataMage RTD Submodule
+
+DataMage provides real-time contextual classification (IAB Categories, Sentiment, Brands, Locations, Public Figures, Restricted Categories, and related IDs) that can be used to enrich demand signals and Google Ad Manager targeting.
+
+## What it does
+
+DataMage automatically supports two outcomes in a Prebid + GAM setup without requiring any custom glue-code on the page:
+
+1) **Passes data to Google Ad Manager (Direct GPT targeting)**
+- The moment Prebid initializes, DataMage fetches classification for the current page and automatically pushes the targeting keys directly to GPT via `googletag.pubads().setTargeting(...)` at the page level.
+- This ensures the data is available for all ad slots and works **even if there are no bids** or if the auction times out.
+
+2) **Passes data to bidders (ORTB2 enrichment)**
+- Using a memoized cache from the initial fetch, DataMage seamlessly inserts the contextual results into the bid request using OpenRTB (`ortb2Fragments.global.site.content.data`), allowing bidders to receive the enriched signals instantly.
+
+## Keys provided
+
+DataMage automatically maps and provides the following targeting keys (when available in the API response):
+
+- `om_iab_cat_ids`
+- `om_iab_cats`
+- `om_brand_ids`
+- `om_sentiment_ids`
+- `om_location_ids`
+- `om_public_figure_ids`
+- `om_restricted_cat_ids`
+- `om_restricted_cats`
+- `om_ops_mage_data_id`
+- `om_res_score_bucket`
+- `om_res_score` (only when present)
+
+
+
+##Prebid config
+```
+pbjs.setConfig({
+ realTimeData: {
+ auctionDelay: 1000, // Gives the module time to fetch data before bids are sent, suggested minimum 1000
+ dataProviders: [{
+ name: "datamage",
+ params: {
+ api_key: "YOUR_API_KEY",
+ selector: "article",
+ fetch_timeout_ms: 2500
+ }
+ }]
+ }
+});
+```
+
+##GAM set up requirements
+
+Because DataMage now automatically injects targeting globally into pubads(), your page implementation only requires a standard Prebid setup.
+
+To ensure DataMage key-values are included in your GAM requests:
+
+Call googletag.pubads().disableInitialLoad() before your ad requests.
+
+Define your slots and call googletag.enableServices().
+
+Run pbjs.requestBids(...).
+
+Inside the bidsBackHandler callback:
+
+Call pbjs.setTargetingForGPTAsync() (to set standard Prebid hb_ pricing keys).
+
+Call googletag.pubads().refresh() to trigger the GAM request.
+
+GAM will automatically combine the standard Prebid slot-level pricing keys with the page-level DataMage contextual keys.
+
+Note that you will need a _real_ api key provisioned by data mage to use this module in production.
+
+Example:
+```pbjs.requestBids({
+ bidsBackHandler: function () {
+ // Push standard header bidding keys to GPT
+ pbjs.setTargetingForGPTAsync();
+
+ // Refresh the ad slots. Datamage keys are already injected!
+ googletag.cmd.push(function () {
+ googletag.pubads().refresh();
+ });
+ },
+ timeout: 1500
+});
+```
\ No newline at end of file
diff --git a/modules/datamageRtdProvider.js b/modules/datamageRtdProvider.js
new file mode 100644
index 00000000000..59b8b4e96f3
--- /dev/null
+++ b/modules/datamageRtdProvider.js
@@ -0,0 +1,211 @@
+import { submodule } from '../src/hook.js';
+import { logError, logWarn, logInfo, generateUUID } from '../src/utils.js';
+import { ajaxBuilder } from '../src/ajax.js';
+
+const MODULE_NAME = 'datamage';
+
+let fetchPromise = null;
+let lastTargeting = null;
+
+function _resetForTest() {
+ fetchPromise = null; // Clear the network promise cache
+ lastTargeting = null; // Clear the data targeting cache
+}
+
+function asStringArray(v) {
+ if (v == null) return [];
+ if (Array.isArray(v)) return v.map((x) => String(x));
+ return [String(v)];
+}
+
+function ensureSiteContentData(globalOrtb2) {
+ if (!globalOrtb2.site) globalOrtb2.site = {};
+ if (!globalOrtb2.site.content) globalOrtb2.site.content = {};
+ if (!Array.isArray(globalOrtb2.site.content.data)) globalOrtb2.site.content.data = [];
+ return globalOrtb2.site.content.data;
+}
+
+function buildSegments(iabCatIds, iabCats) {
+ const ids = asStringArray(iabCatIds);
+ const names = Array.isArray(iabCats) ? iabCats.map((x) => String(x)) : [];
+ return ids.map((id, idx) => {
+ const seg = { id };
+ if (names[idx]) seg.name = names[idx];
+ return seg;
+ });
+}
+
+function padBase64(b64) {
+ const mod = b64.length % 4;
+ return mod ? (b64 + '='.repeat(4 - mod)) : b64;
+}
+
+function stripPortFromUrl(urlStr) {
+ try {
+ const u = new URL(urlStr);
+ if (u.port) u.port = '';
+ return u.toString();
+ } catch (e) {
+ return urlStr;
+ }
+}
+
+function buildApiUrl(params) {
+ const apiKey = params.api_key || '';
+ const selector = params.selector || '';
+ const rawPageUrl = (typeof window !== 'undefined' && window.location?.href) ? window.location.href : '';
+ const pageUrl = stripPortFromUrl(rawPageUrl);
+
+ let encodedUrl = '';
+ try { encodedUrl = padBase64(btoa(pageUrl)); } catch (e) { }
+
+ return `https://opsmage-api.io/context/v3/get?api_key=${encodeURIComponent(apiKey)}&content_id=${encodedUrl}&prebid=true&selector=${encodeURIComponent(selector)}`;
+}
+
+function fetchContextData(apiUrl, fetchTimeoutMs) {
+ if (fetchPromise) return fetchPromise;
+
+ const ajax = ajaxBuilder(fetchTimeoutMs);
+ fetchPromise = new Promise((resolve, reject) => {
+ ajax(apiUrl, {
+ success: (responseText) => {
+ try { resolve(JSON.parse(responseText)); } catch (err) { reject(err); }
+ },
+ error: (err) => reject(err)
+ });
+ });
+
+ return fetchPromise;
+}
+
+/**
+ * Helper to parse the API payload so we don't repeat mapping logic
+ */
+function mapApiPayload(cc) {
+ const arrayKeys = ['brand_ids', 'sentiment_ids', 'location_ids', 'public_figure_ids', 'restricted_cat_ids', 'restricted_cats'];
+ const scalarKeys = ['ops_mage_data_id', 'res_score', 'res_score_bucket'];
+
+ const ext = {};
+ const targetingArrays = {};
+ lastTargeting = {};
+
+ const iabCats = asStringArray(cc.iab_cats);
+ const iabCatIds = asStringArray(cc.iab_cat_ids);
+ targetingArrays.om_iab_cat_ids = iabCatIds;
+ targetingArrays.om_iab_cats = iabCats;
+ lastTargeting.om_iab_cat_ids = iabCatIds.join(',');
+ lastTargeting.om_iab_cats = iabCats.join(',');
+
+ arrayKeys.forEach((key) => {
+ const vals = asStringArray(cc[key]);
+ ext[key] = vals;
+ targetingArrays[`om_${key}`] = vals;
+ lastTargeting[`om_${key}`] = vals.join(',');
+ });
+
+ scalarKeys.forEach((key) => {
+ if (cc[key] != null) {
+ ext[key] = cc[key];
+ targetingArrays[`om_${key}`] = [String(cc[key])];
+ lastTargeting[`om_${key}`] = String(cc[key]);
+ }
+ });
+
+ return { ext, targetingArrays, segment: buildSegments(iabCatIds, iabCats) };
+}
+
+// ==========================================
+// 1. PUBLISHER TARGETING (Independent of Auction)
+// ==========================================
+function init(rtdConfig, userConsent) {
+ logInfo('DATAMAGE: init() called. Fetching data for GAM...');
+
+ const params = (rtdConfig && rtdConfig.params) || {};
+ if (!params.api_key) logWarn('DataMage: Missing api_key');
+
+ const apiUrl = buildApiUrl(params);
+ const fetchTimeoutMs = Number(params.fetch_timeout_ms ?? 2500);
+
+ // Start network request instantly and push to GPT regardless of bids
+ fetchContextData(apiUrl, fetchTimeoutMs).then((resJson) => {
+ if (!resJson?.content_classification) {
+ lastTargeting = null; // FIX: Clear stale cache on empty payload
+ return;
+ }
+
+ const { targetingArrays } = mapApiPayload(resJson.content_classification);
+
+ window.googletag = window.googletag || { cmd: [] };
+ window.googletag.cmd.push(() => {
+ Object.entries(targetingArrays).forEach(([key, value]) => {
+ if (value.length) window.googletag.pubads().setTargeting(key, value);
+ });
+ });
+ }).catch(() => {
+ lastTargeting = null; // FIX: Clear stale cache on error
+ });
+
+ return true;
+}
+
+// ==========================================
+// 2. ADVERTISER TARGETING (Tied to Auction)
+// ==========================================
+function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) {
+ logInfo('DATAMAGE: getBidRequestData() triggered. Attaching to ORTB2...');
+
+ if (!reqBidsConfigObj?.ortb2Fragments?.global) {
+ callback();
+ return;
+ }
+
+ const params = (rtdConfig && rtdConfig.params) || {};
+ const apiUrl = buildApiUrl(params);
+ const fetchTimeoutMs = Number(params.fetch_timeout_ms ?? 2500);
+
+ reqBidsConfigObj.auctionId = reqBidsConfigObj.auctionId || generateUUID();
+
+ // This will instantly resolve from the cache created in init()
+ fetchContextData(apiUrl, fetchTimeoutMs)
+ .then((resJson) => {
+ if (!resJson?.content_classification) {
+ lastTargeting = null; // FIX: Clear stale cache on empty payload
+ return;
+ }
+
+ const { ext, segment } = mapApiPayload(resJson.content_classification);
+
+ const ortbContentDataObj = { name: 'data-mage.com', segment, ext };
+ ensureSiteContentData(reqBidsConfigObj.ortb2Fragments.global).push(ortbContentDataObj);
+ })
+ .catch((error) => {
+ lastTargeting = null; // FIX: Clear stale cache on error
+ logError('DataMage: Fetch error', error);
+ })
+ .finally(() => callback()); // Release the auction!
+}
+
+function getTargetingData(adUnitCodes, rtdConfig, userConsent) {
+ if (!lastTargeting) return {};
+
+ const out = {};
+
+ // Iterate over the array of string codes passed by Prebid
+ (adUnitCodes || []).forEach((code) => {
+ if (typeof code === 'string' && code) {
+ out[code] = { ...lastTargeting };
+ }
+ });
+
+ return out;
+}
+
+export const datamageRtdSubmodule = {
+ name: MODULE_NAME,
+ init,
+ getBidRequestData,
+ getTargetingData,
+ _resetForTest
+};
+
+submodule('realTimeData', datamageRtdSubmodule);
diff --git a/test/spec/modules/datamageRtdProvider_spec.js b/test/spec/modules/datamageRtdProvider_spec.js
new file mode 100644
index 00000000000..9662ed168ba
--- /dev/null
+++ b/test/spec/modules/datamageRtdProvider_spec.js
@@ -0,0 +1,246 @@
+import { expect } from 'chai';
+import sinon from 'sinon';
+import { datamageRtdSubmodule } from 'modules/datamageRtdProvider.js';
+import * as ajaxUtils from 'src/ajax.js';
+import * as utils from 'src/utils.js';
+
+describe('datamageRtdSubmodule (DataMage RTD Provider)', function () {
+ let sandbox;
+ let ajaxBuilderStub;
+ let setTargetingStub;
+ let btoaStub;
+ let origGoogletag; // Stores the original global to prevent breaking other tests
+
+ function makeReqBidsConfigObj() {
+ return {
+ auctionId: 'auction-1',
+ ortb2Fragments: { global: {} }
+ };
+ }
+
+ function makeProcessedResponse(overrides = {}) {
+ return {
+ content_classification: {
+ ops_mage_data_id: '7d54b2d30a4e441a0f698dfae8f5b1b5',
+ res_score: 1,
+ res_score_bucket: 'high',
+ iab_cats: [
+ 'Technology & Computing',
+ 'Technology & Computing|Artificial Intelligence',
+ 'Business & Finance'
+ ],
+ iab_cat_ids: ['596', '597', '52'],
+ brand_ids: ['eefd8446', 'b78b9ee2'],
+ sentiment_ids: ['95487831', '92bfd7eb'],
+ location_ids: ['60efc224'],
+ public_figure_ids: ['55eefb4a'],
+ restricted_cat_ids: [],
+ ...overrides
+ }
+ };
+ }
+
+ beforeEach(function () {
+ sandbox = sinon.createSandbox();
+
+ // Stub logging so they don't spam the test console
+ sandbox.stub(utils, 'logInfo');
+ sandbox.stub(utils, 'logWarn');
+ sandbox.stub(utils, 'logError');
+
+ // Reset module-scoped cache
+ datamageRtdSubmodule._resetForTest();
+
+ // Safely backup the original googletag object
+ origGoogletag = window.googletag;
+
+ // Mock window.googletag and spy on setTargeting
+ setTargetingStub = sandbox.stub();
+ window.googletag = {
+ cmd: {
+ push: function (fn) { fn(); } // Execute immediately for testing
+ },
+ pubads: function () {
+ return { setTargeting: setTargetingStub };
+ }
+ };
+
+ // Stub Prebid's internal ajaxBuilder
+ ajaxBuilderStub = sandbox.stub(ajaxUtils, 'ajaxBuilder');
+
+ // Keep tests deterministic + allow port-strip assertion
+ btoaStub = sandbox.stub(window, 'btoa').callsFake((s) => `b64(${s})`);
+ });
+
+ afterEach(function () {
+ sandbox.restore();
+ // Restore the original googletag object so we don't break the E-Planning adapter
+ window.googletag = origGoogletag;
+ });
+
+ describe('init()', function () {
+ it('should return true and trigger GAM injection asynchronously', function (done) {
+ let fakeAjax = sinon.stub();
+ ajaxBuilderStub.returns(fakeAjax);
+
+ const ok = datamageRtdSubmodule.init({ name: 'datamage', params: { api_key: 'x' } }, {});
+ expect(ok).to.equal(true);
+
+ // Simulate the API resolving
+ const callbacks = fakeAjax.firstCall.args[1];
+ callbacks.success(JSON.stringify(makeProcessedResponse()));
+
+ // Use setTimeout to wait for the Promise chain to resolve
+ setTimeout(() => {
+ expect(setTargetingStub.calledWith('om_iab_cat_ids', ['596', '597', '52'])).to.be.true;
+ expect(setTargetingStub.calledWith('om_brand_ids', ['eefd8446', 'b78b9ee2'])).to.be.true;
+ expect(setTargetingStub.calledWith('om_res_score', ['1'])).to.be.true;
+ expect(setTargetingStub.calledWith('om_restricted_cat_ids')).to.be.false;
+ done();
+ }, 0);
+ });
+ });
+
+ describe('getBidRequestData()', function () {
+ it('should inject into ORTB2 when fetch resolves', function (done) {
+ const req = makeReqBidsConfigObj();
+ let fakeAjax = sinon.stub();
+ ajaxBuilderStub.returns(fakeAjax);
+
+ const rtdConfig = {
+ name: 'datamage',
+ params: { api_key: 'k', selector: 'article' }
+ };
+
+ datamageRtdSubmodule.getBidRequestData(req, () => {
+ expect(req.ortb2Fragments.global).to.have.nested.property('site.content.data');
+ const dataArr = req.ortb2Fragments.global.site.content.data;
+ expect(dataArr).to.be.an('array').with.length.greaterThan(0);
+ expect(dataArr[0]).to.have.property('name', 'data-mage.com');
+ expect(dataArr[0]).to.have.property('segment');
+ expect(dataArr[0].segment).to.deep.include({ id: '596', name: 'Technology & Computing' });
+ done();
+ }, rtdConfig, {});
+
+ const callbacks = fakeAjax.firstCall.args[1];
+ callbacks.success(JSON.stringify(makeProcessedResponse()));
+ });
+
+ it('should only make ONE network request when init and getBidRequestData are both called (Memoization)', function (done) {
+ const req = makeReqBidsConfigObj();
+ let fakeAjax = sinon.stub();
+ ajaxBuilderStub.returns(fakeAjax);
+
+ const rtdConfig = { params: { api_key: 'k' } };
+
+ // 1. Init fires (simulating page load)
+ datamageRtdSubmodule.init(rtdConfig);
+
+ // 2. getBidRequestData fires (simulating auction start)
+ datamageRtdSubmodule.getBidRequestData(req, () => {
+ // Assert the network was only hit once despite two entry points
+ expect(fakeAjax.calledOnce).to.be.true;
+ done();
+ }, rtdConfig, {});
+
+ const callbacks = fakeAjax.firstCall.args[1];
+ callbacks.success(JSON.stringify(makeProcessedResponse()));
+ });
+
+ it('should NOT inject after network error', function (done) {
+ const req = makeReqBidsConfigObj();
+ let fakeAjax = sinon.stub();
+ ajaxBuilderStub.returns(fakeAjax);
+
+ datamageRtdSubmodule.getBidRequestData(req, () => {
+ expect(req.ortb2Fragments.global.site?.content?.data).to.be.undefined;
+ expect(setTargetingStub.called).to.be.false;
+ done();
+ }, { name: 'datamage', params: { api_key: 'k' } }, {});
+
+ const callbacks = fakeAjax.firstCall.args[1];
+ callbacks.error('Network Failed');
+ });
+
+ it('should strip port from URL before encoding', function (done) {
+ const req = makeReqBidsConfigObj();
+ let fakeAjax = sinon.stub();
+ ajaxBuilderStub.returns(fakeAjax);
+
+ datamageRtdSubmodule.getBidRequestData(req, () => {
+ expect(btoaStub.called).to.equal(true);
+ const btoaArg = btoaStub.firstCall.args[0];
+
+ expect(btoaArg).to.be.a('string');
+ expect(btoaArg).to.not.match(/\/\/[^/]+:\d+\//);
+ done();
+ }, { name: 'datamage', params: { api_key: 'k' } }, {});
+
+ const callbacks = fakeAjax.firstCall.args[1];
+ callbacks.error('err');
+ });
+
+ it('should clear stale cache (lastTargeting) if the fetch yields no payload', function (done) {
+ const req1 = makeReqBidsConfigObj();
+ const req2 = makeReqBidsConfigObj();
+ let fakeAjax = sinon.stub();
+ ajaxBuilderStub.returns(fakeAjax);
+
+ const rtdConfig = { name: 'datamage', params: { api_key: 'k' } };
+
+ // 1. First auction: successful fetch populates the cache
+ datamageRtdSubmodule.getBidRequestData(req1, () => {
+ // 2. Verify cache is populated
+ let out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {});
+ expect(out['div-1']).to.have.property('om_res_score', '1');
+
+ // 3. Reset module state safely via the internal helper
+ datamageRtdSubmodule._resetForTest();
+
+ // 4. Second auction: simulate an empty response
+ datamageRtdSubmodule.getBidRequestData(req2, () => {
+ // 5. Verify the cache was wiped out
+ out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {});
+ expect(out).to.deep.equal({});
+ done();
+ }, rtdConfig, {});
+
+ // Resolve the second auction with an empty payload
+ const callbacks2 = fakeAjax.secondCall.args[1];
+ callbacks2.success(JSON.stringify({}));
+ }, rtdConfig, {});
+
+ // Resolve the first auction with a good payload
+ const callbacks1 = fakeAjax.firstCall.args[1];
+ callbacks1.success(JSON.stringify(makeProcessedResponse()));
+ });
+ });
+
+ describe('getTargetingData()', function () {
+ it('should return {} if no successful fetch has happened yet', function () {
+ const out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {});
+ expect(out).to.deep.equal({});
+ });
+
+ it('should return per-adunit legacy targeting (string-joined lists) after response resolves', function (done) {
+ const req = makeReqBidsConfigObj();
+ let fakeAjax = sinon.stub();
+ ajaxBuilderStub.returns(fakeAjax);
+
+ datamageRtdSubmodule.getBidRequestData(req, () => {
+ const out = datamageRtdSubmodule.getTargetingData(['div-1', 'div-2'], {}, {});
+ expect(out).to.have.property('div-1');
+ expect(out).to.have.property('div-2');
+
+ expect(out['div-1']).to.have.property('om_iab_cat_ids', '596,597,52');
+ expect(out['div-1']).to.have.property('om_brand_ids', 'eefd8446,b78b9ee2');
+ expect(out['div-1']).to.have.property('om_res_score', '1');
+
+ done();
+ }, { name: 'datamage', params: { api_key: 'k' } }, {});
+
+ const callbacks = fakeAjax.firstCall.args[1];
+ callbacks.success(JSON.stringify(makeProcessedResponse()));
+ });
+ });
+});