, 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();
}
});
});
})();
Non-Saturday testing is available only for students who cannot test on a Saturday because of religious convictions. If there is a non-Saturday test center within 75 miles of your home for any test date, register for that date and request that center.
When you search for a test center both Saturday and non-Saturday test centers will appear in your search results. If you select "Non-Saturday," only the test centers offering non-Saturday testing will appear in your search results.
When you search for a test center in , both Saturday and non-Saturday test centers will be listed together.
This action will open a new window. Do you want to proceed?
Welcome to ACT
If you are accessing this site from outside the United States, Puerto Rico, or U.S. Territories, please proceed to the non-U.S. version of our website.