PreACT FAQs
PreACTÂ Alignment to the Enhanced ACT
To learn more about all the changes to PreACT and the ACT, visit act.org/enhancementsk12
, so that * hubspotform.js (which only loads later, inside the body, on pages that * actually have a HubSpot Form component) can read the same values * without duplicating them. hubspotform.js reads this lazily (at init * time, not at parse time) so it never depends on this script having run * first -- treat that as defense-in-depth, not a reason to relax this * script's own placement in
. */ window.HS_CONFIG = window.HS_CONFIG || Object.freeze({ PORTAL_ID: '24131897', GPC_FORM_GUID: '2b423b38-65e9-4811-8fdc-85e52e7d29d8', DEFAULT_REGION: 'na1' }); const PORTAL_ID = window.HS_CONFIG.PORTAL_ID; const GPC_FORM_GUID = window.HS_CONFIG.GPC_FORM_GUID; // ---------- Generic cookie helpers ---------- function getCookie(name) { const match = document.cookie.match( new RegExp('(^|;\\s*)' + name.replace(/[.*+?^{}()|[\]\\$]/g, '\\$&') + '=([^;]*)') ); return match ? decodeURIComponent(match[2]) : null; } function setCookie(name, value, days) { const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString(); document.cookie = name + '=' + encodeURIComponent(value) + '; expires=' + expires + '; path=/; SameSite=Lax; Secure;'; } // ---------- OptanonConsent parsing ---------- // // OptanonConsent is a URL-encoded, semi-structured query string, e.g.: // isGpcEnabled=1&datestamp=2026-09-11T14:32:10Z&version=202401.1.0& // groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A0&... // // Note: OneTrust's cookie format has changed across versions -- verify // field names against your account's actual cookie before relying on // this in prod. function parseOptanonConsent() { const raw = getCookie('OptanonConsent'); if (!raw) return null; const params = new URLSearchParams(raw); const groupsRaw = params.get('groups') || ''; const groups = {}; groupsRaw.split(',').forEach((pair) => { const parts = pair.split(':'); const id = parts[0]; const status = parts[1]; if (id) groups[id] = status === '1'; }); return { isGpcEnabled: params.get('isGpcEnabled') === '1', datestamp: params.get('datestamp'), groups: groups }; } // ---------- Status tracking cookie (keyed by email) ---------- // // Keyed by email rather than a single flat record. On a shared device, a // flat "last status sent" record gets overwritten by whoever used the // browser most recently, causing one person's status to look "already // sent" for someone else. Keying by email keeps each person's dedupe // state separate even on a shared browser/cookie jar. const STATUS_COOKIE_NAME = 'hs_gpc_status_records'; const STATUS_COOKIE_DAYS = 365; function normalizeEmail(email) { return String(email || '').trim().toLowerCase(); } function getAllStatusRecords() { const raw = getCookie(STATUS_COOKIE_NAME); if (!raw) return {}; try { return JSON.parse(raw) || {}; } catch (e) { return {}; } } function getStatusRecordForEmail(email) { const key = normalizeEmail(email); if (!key) return null; return getAllStatusRecords()[key] || null; } function setStatusRecordForEmail(email, lastSentGpcStatus, submissionStatus) { const key = normalizeEmail(email); if (!key) return; const records = getAllStatusRecords(); records[key] = { lastSentGpcStatus: lastSentGpcStatus, lastUpdated: new Date().toISOString(), submissionStatus: submissionStatus // "submitted" | "failed" | "pending" }; // Cookies have a ~4KB practical limit -- prune oldest entries beyond a // reasonable count so this doesn't grow unbounded on shared devices. const MAX_RECORDS = 20; const keys = Object.keys(records); if (keys.length > MAX_RECORDS) { const oldestFirst = keys.sort( (a, b) => new Date(records[a].lastUpdated) - new Date(records[b].lastUpdated) ); oldestFirst.slice(0, keys.length - MAX_RECORDS).forEach((k) => delete records[k]); } setCookie(STATUS_COOKIE_NAME, JSON.stringify(records), STATUS_COOKIE_DAYS); } // ---------- Email-identified submission path (preferred) ---------- async function maybeSubmitGpcStatusForEmail(email) { const key = normalizeEmail(email); if (!key) return; const consent = parseOptanonConsent(); if (!consent) return; // OneTrust hasn't initialized yet const currentGpcStatus = consent.isGpcEnabled === true; const existing = getStatusRecordForEmail(key); if ( existing && existing.lastSentGpcStatus === currentGpcStatus && existing.submissionStatus === 'submitted' ) { return; // Already recorded this exact status for this specific person } setStatusRecordForEmail(key, currentGpcStatus, 'pending'); try { await submitGpcStatusUpdate({ email: key, gpcStatus: currentGpcStatus }); setStatusRecordForEmail(key, currentGpcStatus, 'submitted'); } catch (err) { setStatusRecordForEmail(key, currentGpcStatus, 'failed'); } } // ---------- hubspotutk-only background path (fallback, higher risk) ---------- // // Browser-identified, not person-identified -- can misattribute status on // a genuinely shared device. Kept as a fallback for visitors who never // submit a form. const HUBSPOTUTK_RETRY_ATTEMPTS = 5; const HUBSPOTUTK_RETRY_DELAY_MS = 1000; async function maybeSubmitGpcStatusBackground(retriesLeft = HUBSPOTUTK_RETRY_ATTEMPTS) { const hubspotutk = getCookie('hubspotutk'); if (!hubspotutk) { // hubspotutk is written by HubSpot's own tracking script, which only runs // (and sets this cookie) after it processes the _hsq doNotTrack push from // syncHubSpotDoNotTrack — it may not exist yet the instant consent // resolves. Retry for a bounded window rather than giving up on one miss. if (retriesLeft > 0) { setTimeout(() => maybeSubmitGpcStatusBackground(retriesLeft - 1), HUBSPOTUTK_RETRY_DELAY_MS); } return; } const consent = parseOptanonConsent(); if (!consent) return; const currentGpcStatus = consent.isGpcEnabled === true; const dedupeKey = 'hutk:' + hubspotutk; const existing = getStatusRecordForEmail(dedupeKey); if ( existing && existing.lastSentGpcStatus === currentGpcStatus && existing.submissionStatus === 'submitted' ) { return; } setStatusRecordForEmail(dedupeKey, currentGpcStatus, 'pending'); try { await submitGpcStatusUpdate({ hubspotutk: hubspotutk, gpcStatus: currentGpcStatus }); setStatusRecordForEmail(dedupeKey, currentGpcStatus, 'submitted'); } catch (err) { setStatusRecordForEmail(dedupeKey, currentGpcStatus, 'failed'); } } async function submitGpcStatusUpdate(opts) { const email = opts.email; const hubspotutk = opts.hubspotutk; const gpcStatus = opts.gpcStatus; const fields = [ { name: 'consent_optout_gpc', value: String(gpcStatus) }, { name: 'consent_last_updated_date_gpc', value: new Date().toISOString() } ]; if (email) fields.push({ name: 'email', value: email }); const response = await fetch( 'https://api.hsforms.com/submissions/v3/integration/submit/' + PORTAL_ID + '/' + GPC_FORM_GUID, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fields: fields, context: hubspotutk ? { hutk: hubspotutk } : {} }) } ); if (!response.ok) throw new Error('HubSpot form submission failed: ' + response.status); return response.json(); } // Kept as a defensive fallback in case this account's OneTrust setup does // dispatch it in some configurations — doesn't hurt to also listen. function onOneTrustGroupsUpdated() { maybeSubmitGpcStatusBackground(); } window.addEventListener('OneTrustGroupsUpdated', onOneTrustGroupsUpdated); if (window.OneTrust) { // Fast path for when consent is already known (e.g. on a refresh, or a // returning visitor) — avoids waiting on an event that may not fire again // if consent isn't changing this load. maybeSubmitGpcStatusBackground(); } // ---------- HubSpot form submission hooks (email-identified, preferred) ---------- // Iframe embed (postMessage-based) -- also covers hbspt.forms.create(), // since that renders the same underlying iframe and fires the same // postMessage events. window.addEventListener('message', (event) => { if (!event.data || event.data.type !== 'hsFormCallback') return; if (event.data.eventName !== 'onFormSubmitted') return; const email = event.data.data && event.data.data.submissionValues ? event.data.data.submissionValues.email : null; if (email) { maybeSubmitGpcStatusForEmail(email); } else { maybeSubmitGpcStatusBackground(); } }); // HubSpot's non-iframe ("raw HTML") / v4 forms dispatch this CustomEvent // on window. event.detail only carries { formId, instanceId } -- actual // field values must be fetched via the async getFormFieldValues() call. window.addEventListener('hs-form-event:on-submission:success', (event) => { if (!window.HubSpotFormsV4) return; HubSpotFormsV4.getFormFromEvent(event) .getFormFieldValues() .then((fieldValues) => { // "0-1" is HubSpot's fixed objectTypeId for Contact -- forms with // fields on multiple objects (e.g. Contact + Ticket) can have more // than one "email" field, so target the Contact's explicitly. const emailField = fieldValues.find((field) => field.name === '0-1/email'); const email = emailField ? emailField.value : null; if (email) { maybeSubmitGpcStatusForEmail(email); } else { maybeSubmitGpcStatusBackground(); } }); }); })();To learn more about all the changes to PreACT and the ACT, visit act.org/enhancementsk12