Top One Futures × Axcera · Integration Spec · Revision 3
What we would like pushed to window.dataLayer on the Top One Futures TraderArea, so that offers, prompts and in-app messaging can be built and iterated entirely on our side without another platform release. Top One Trader follows on the identical schema once this is proven.
This launches on Top One Futures first. The container shared so far, GTM-KXQQ6G4S, belongs to the Top One Trader TraderArea at app.toponetrader.com. The Futures TraderArea at portal.toponefutures.com currently loads no GTM container at all, so step one is installing one there. The container is GTM-K7XMHV9M, created in Top One's own GTM account; please embed it on the Futures tenant the same way GTM-KXQQ6G4S is embedded in the Trader app. Everything below is written so the identical schema ships to Trader afterwards without changes.
We agree with making this global rather than building it around a single breach event, and we have gone further in that direction: phase 1 is one event.
Axcera pushes facts about the user and their accounts. Status, timestamps, plan, size, counts. These change rarely and are stable to implement against.
Top One decides what is shown. Which traders see a prompt, what it says, what it offers, for how long, and when it stops. That logic changes weekly during a campaign, and every one of those changes should cost you nothing.
If a field would ever need updating because we changed a promotion, it is on the wrong side of the line. Nothing in this document carries a price, a discount, a coupon, a deadline or an eligibility rule.
The fields below reuse the names and meanings from your prop-accounts read API because it makes your implementation cheap: serialise the agreed fields from objects you already have. It is not a substitute for freshness. Before we show an offer, and again at redemption, we revalidate the account against your API server-side, so a stale page can never grant anything.
A single event, topone_context, carrying a complete snapshot under its own topone namespace. It is a picture of the current state, never a "something just happened" signal. We derive everything we need from it, including whether an account is breached.
// Initialise once, before the first push. Never reassign an existing dataLayer.
window.dataLayer = window.dataLayer || [];
// Clear only our namespace, then push a COMPLETE snapshot. GTM keeps the last value of any
// key that a later push omits, so a partial push would let a stale account list survive.
window.dataLayer.push({ topone: null });
window.dataLayer.push({
event: "topone_context",
topone: {
schemaVersion: 1,
brand: "topone_futures",
environment: "production",
pageInstanceId: "9c1f4e2a", // new per full page load
sequence: 12, // increases with every push in this page instance
observedAt: "2026-09-08T18:00:00Z",
page: { navigationId: "n_0007", type: "account_detail", state: "ready" }, // navigationId: new per client-side navigation
user: {
userId: "5f3a9c1e2b7d4a6081c2e9f0",
accountsTotal: 7,
accountsActive: 2,
accountsBreached: 4,
accountsFunded: 1,
breachesLifetime: 9,
firstPurchaseAt: "2026-03-11T09:02:14Z",
lastPurchaseAt: "2026-09-02T16:27:41Z"
},
account: { // the account in context, else null
accountId: "64d1f0a2b3c4e5f6a7b8c9d0",
accountNr: "TOF-PX-100001",
status: "breached",
phase: "evaluation",
planId: "elite-daily-50k",
plan: "Elite Daily | $50,000",
accountSize: 50000,
platform: "tradovate",
createdAt: "2026-08-29T21:40:03Z",
breachedAt: "2026-09-03T10:30:00.412Z", // native precision, never truncated
isReset: false, // native meaning: a reset was performed on this account
resetTo: null, // replacement account id once reset
resetAt: null,
isRepeated: false, repeatedTo: null,
isUpgraded: false, upgradedTo: null,
updatedAt: "2026-09-08T17:58:41.007Z" // when your record last changed
},
accounts: null, // array of the same objects on list pages
accountsScope: "not_loaded" // "all" | "visible_page" | "not_loaded"
}
});
page.navigationId, page.state: "loading", account: null and accountsScope: "not_loaded". This is what takes a prompt down instantly; nothing else can.navigationId and page.state: "ready". Discard any account response that belongs to an earlier navigation instance instead of pushing it: a higher sequence cannot make an old response current.page.type: "checkout" and the account context cleared. Silence cannot remove a prompt that is already on screen; a snapshot can. Route type is a fact; whether marketing belongs there is our decision.user: null so anything on screen is removed and nothing is attributed to the wrong person.Every push carries all of user, account and accounts, using null when not applicable. accountsScope says whether an array is the whole portfolio, one visible page, or not loaded at all; an empty array [] means a known empty result. The counts on user are backend totals and never depend on what is visible.
| Field | Type | Definition | |
|---|---|---|---|
schemaVersion | integer | Starts at 1. A renamed key, changed type, changed nullability or changed meaning is a new version. Added keys are not. | required |
brand | enum | topone_futures now, topone_trader later. Keeps one tag safe across both sites. | required |
environment | enum | production or staging, so a test environment can never trigger a live offer. | required |
pageInstanceId | string | New on every full page load. With sequence, lets us discard out-of-order or duplicate snapshots. | required |
sequence | integer | Increments on every push within a page instance. | required |
observedAt | ISO 8601 | When this snapshot was assembled in the browser. Not a freshness guarantee; our server revalidates before anything is shown. | required |
page.navigationId | string | New for every client-side navigation, so account A to account B is two instances even though the route type is the same. Both the loading and the ready snapshot of one navigation carry the same id. | required |
page.type | enum | Please send the full list. We need at least dashboard, account list, account detail, checkout, payment, other. | required |
page.state | enum | loading or ready. We act only on ready. | required |
| Field | Type | Definition | |
|---|---|---|---|
userId | string | Your stable customer id. Our key for joining, capping and dedupe. Always a string, even if numeric. | required |
accountsTotal | integer | All accounts this user has ever owned, any status. | required |
accountsActive | integer | Accounts currently tradable. Please state exactly which statuses count; pending activation must not, since that person needs an activation prompt, not a win-back. | required |
accountsBreached | integer | Accounts currently breached and not yet reset. A point-in-time count. | required |
breachesLifetime | integer | Breach occurrences across the user's history, null if unknown. Not needed for launch and not worth reconstructing history for; we can derive it from your read API ourselves. | nice |
accountsFunded | integer | Accounts currently in a funded phase. Not "ever funded". | required |
firstPurchaseAt / lastPurchaseAt | ISO 8601 | Paid account purchases only: not resets, activation fees, giveaways or replacements. null for a user who has never bought, which is different from a lapsed customer. | nice |
| Field | Type | Definition | |
|---|---|---|---|
accountId | string | Your account id. It is already stamped on the orders we receive, so it ties a prompt to a purchase end to end. | required |
accountNr | string | The number the trader recognises, e.g. TOF-PX-100001. Shown so they know which account we mean. | required |
status | enum | The state we react to. We need the complete list of values you can emit. Any value we do not recognise suppresses the campaign for that account and raises a diagnostic on our side; we never guess. | required |
phase | enum | evaluation or funded. Resets are different products at different prices in each. | required |
planId | string | Stable, enumerable identifier we map to a product. Full list needed. | required |
accountSize | integer | Account size in whole US dollars. 50000, never "$50,000". | required |
breachedAt | ISO 8601 | When this account breached, at your native precision. null when not breached. | required |
resetTo | string | The replacement account created by a reset, null until then. Your read API already exposes this, which suggests a reset creates a new account rather than reusing the id. If that holds, the breached accountId is the breach occurrence and we need no separate breach id. Please confirm with the examples in section 9. | required |
isReset / resetAt | bool / ISO | Exactly your native meaning, not ours: a reset was performed on this account, and when. We will not redefine an existing API field; if the native meaning differs from what we assume here, the examples in section 9 will surface it. | required |
isRepeated / repeatedToisUpgraded / upgradedTo | bool / string | Native lineage fields, same meaning as your read API. They let us walk an account's chain (reset, repeat, upgrade) without inventing counters. | nice |
updatedAt | ISO 8601 | When your record last changed. It tells us the record's age, not whether the copy we received is current, so our server revalidation stays regardless. | required |
plan | string | Human-readable plan name for the message body. Rendered as text only, never as markup. | nice |
platform | enum | Trading platform. Full list needed. | nice |
createdAt | ISO 8601 | Account creation time. | nice |
Please serialise only the fields agreed here rather than your whole API object, so a future field added to your API does not silently become part of this contract.
Phase 1 needs none of these. They are listed so the envelope you build now accommodates them without a second design round. Each is a moment in time and must carry its own occurrence id, because an account object alone cannot distinguish two payouts or two breaches.
accountId and resetTo, so we can close exactly the prompt that was open.payoutId, completedAt, the account and a definition of "completed". Best moment for a review or referral ask.Anything that must be captured while the trader is offline, such as a reset or payout for durable history, belongs in a server-to-server webhook rather than the browser. That is a separate conversation and does not affect phase 1.
The snapshot fires on every authenticated TraderArea route. We would rather receive it on checkout and payment pages with page.type saying so than receive nothing there, because nothing cannot take a prompt down. Our tag suppresses on those types and removes anything already mounted.
The routes where a prompt will actually appear are the dashboard, the account list and the account detail page, with that account in account so a prompt can be specific to the one the trader is looking at.
status, phase, platform, planId and page.type before the first test.null means unknown or not applicable. It is never a stand-in for false, 0 or an empty list.schemaVersion; tell us before it ships.sequence seen for the current pageInstanceId and navigationId, and discard anything older, from a previous navigation, or from a previous user.The dataLayer is client-side. A trader can push any event from a browser console, and any script on the page can read what is there. So it only ever decides what is displayed. Which offer a trader is entitled to, its deadline and its terms are issued by a Top One endpoint that checks your API and our order records, and the checkout revalidates the same offer before granting anything. Nobody on either side should treat the dataLayer as a security boundary.
userId and the breached accountId, so a second device does not show it again.userId is pseudonymous personal data and is kept out of advertising tags. No email, hashed or otherwise, is in this contract. Only fields we explicitly allowlist are forwarded to analytics; nothing here is forwarded by default.GTM-K7XMHV9M on the Futures TraderArea and implement topone_context with the phase 1 fields.The single-account test proves the plumbing. Before we widen it, we will exercise on our side: first breach, reset, second breach on the replacement account; account A to account B with a slow response for A still in flight; several breached accounts and a paginated list; detail to dashboard to checkout; logout and login as another user; slow or out-of-order responses while the dashboard stays open; two tabs and two devices; storage unavailable; expiry during checkout and a duplicate redemption attempt; and the published CSP outside Preview. Agreed that testing happens on production and that anything published is live immediately.
portal.toponefutures.com loads no GTM today. Please embed GTM-K7XMHV9M on the Futures tenant (head snippet plus noscript frame, as on Trader) and tell us which routes, if any, are excluded. The container lives in Top One's account and publish rights stay with our team.status, phase, platform, planId and page.type.isReset, resetTo and resetAt with a concrete example of each: a first breach, a completed reset, a second breach, and a reset that creates a replacement account. We will adopt your meaning rather than impose one.script-src, style-src, connect-src, img-src and font-src allow. Our loader, its stylesheet and our offer endpoint need to be permitted, and Preview mode has its own requirements.userId is not authentication, and your read API can confirm ownership but not who is asking. We need one of: a same-origin endpoint on the TraderArea that returns a short-lived signed assertion for the logged-in session (a JWT over userId and tenant, audience topone-offers, about five minutes, keys held server-side on both ends), which our script passes directly to our endpoint and never into the dataLayer; an authenticated proxy on your side; or an existing session mapping we can verify. The assertion proves identity only; eligibility is still checked live.