The QuadraScan SDK adds a full-body 3D scan to your site with one <script> tag, init(), and startScan(). The flow (camera, pose detection, mesh generation, measurements) runs in a hosted iframe — your page does not run the scan logic.
Place this in your <head> or just before </body>:
<script src="https://quadrascan.fitmatch.ai/sdk/quadrascan.js"></script>
Call QuadraScan.init() once — before any scan is started. A good place is a DOMContentLoaded handler or immediately after the script tag.
QuadraScan.init({
apiKey: 'YOUR_LICENSE_KEY', // required — key issued to your domain
teamId: 'YOUR_TEAM_ID', // required — associates scans with your team (never shown to the user)
club: 'YOUR_CLUB_NAME', // optional — club name (e.g. 'FC Dallas')
athleteId: 'YOUR-OWN-UUID', // optional — caller-supplied UUID (v4) to identify the athlete
scanType: 'both', // optional: 'self' | 'manual' | 'both' (default: 'both')
units: 'imperial', // optional: 'imperial' (default) | 'metric' — display unit system
results: { // optional — controls which result sections are shown
showPhv: true, // Body Maturation (PHV gauge)
showBri: true, // Body Insights (BRI + WHtR gauges)
showAnthropometrics: true, // Anthropometrics tables (Full Body, Circumferences, Breadth)
},
tutorial: { // optional — controls pre-scan tutorial behavior
enabled: true, // show/hide tutorial and tutorial buttons entirely
required: true, // first-time users must watch before scanning
requireEveryTime: false, // if true, ignores localStorage and always shows tutorial
},
theme: { // optional — re-skin the iframe in your brand
backgroundColor: '#0b1320',
surfaceCardColor: '#141d2f',
onSurfaceCardColor: '#ffffff',
popupBackgroundColor: '#1f2533',
popupTextColor: '#efefef',
scanTileBackgroundColor: '#141d2f',
onScanTileColor: '#c8c8c8',
primaryColor: '#e30613',
onPrimaryColor: '#ffffff',
secondaryColor: '#465064',
onSecondaryColor: '#f6f6f6',
textColor: '#ffffff',
fontFamily: "'Inter', -apple-system, sans-serif",
buttonRadius: '10px',
},
});
Pre-filling athlete data (optional)
If you already have the athlete’s information, pass an athlete object to pre-fill the Athlete Info step:
TOU → Athlete Info → Scan Type → Camera/Upload → Viewer
QuadraScan.init({
apiKey: 'YOUR_LICENSE_KEY',
teamId: 'YOUR_TEAM_ID',
club: 'FC Dallas', // optional — club name stored with the athlete record
clientName: 'FC Dallas', // optional — shown in the client TOU screen
clientTouUrl: 'https://fcdallas.com/terms', // optional — your Terms of Use URL
clientPpUrl: 'https://fcdallas.com/privacy', // optional — your Privacy Policy URL
athlete: {
firstName: 'Jane',
lastName: 'Doe',
gender: 'Female', // case-insensitive: 'male' | 'female' | 'other'
dob: '2005-08-14', // YYYY-MM-DD (must be a real calendar date)
heightCm: 170, // metric height (cm). Converted to ft/in internally.
// OR use imperial instead of heightCm:
// heightFt: 5,
// heightIn: 7,
weight: 59, // weight as a number
weightUnit: 'metric', // 'imperial' (lbs) | 'metric' (kg) — default: 'imperial'
},
scanType: 'both', // optional: 'self' | 'manual' | 'both' (default: 'both')
showResults: true, // optional: show measurements screen after scan (default: true)
units: 'metric', // optional: 'imperial' (default) | 'metric'
results: { // optional: hide individual result sections
showPhv: false, // hide Body Maturation (PHV)
showBri: true,
showAnthropometrics: true,
},
width: '1000px', // optional: max width of the overlay (default: '1000px')
height: '90vh', // optional: max height of the overlay (default: '90vh')
});
The SDK trims string fields, normalizes gender to lowercase (Male and male both work),
validates dob as a real YYYY-MM-DD date, and always converts height and weight to
imperial units (ft/in and lbs) before sending athlete data to the API.
Attach QuadraScan.startScan() to any button or event.
document.getElementById('scan-btn').addEventListener('click', () => {
QuadraScan.startScan({
onComplete(data) {
// 'data' contains the full result payload.
console.log('Scan complete:', data);
},
onError(err) {
console.error('Scan error:', err);
},
onCancel() {
// Fired only when the user dismissed without completing.
console.log('User closed the scan without completing.');
},
onScanStart() {
console.log('Scan overlay opened.');
},
onPhotoCapture({ pose }) {
// pose is 'front' | 'back' | 'left' | 'right'
analytics.track('photo_captured', { pose });
},
onUpload() {
analytics.track('scan_upload_started');
},
onClose() {
// Always fires when the overlay is removed, regardless of how it closed.
console.log('Scan overlay closed.');
},
});
});
init() Options| Option | Type | Required | Default | Description |
|---|---|---|---|---|
apiKey |
string | Yes | — | License key issued for your domain. |
teamId |
string | Yes | — | Team ID to associate all scans with. Never shown to the end user. |
club |
string | No | '' |
Club name associated with the athlete (e.g. 'FC Dallas'). Stored with the athlete record. |
athleteId |
string | No | '' |
Caller-supplied UUID (v4) to identify the athlete. If an athlete with this UUID already exists it is returned as-is (idempotent); if not, a new athlete is created using this UUID instead of an auto-generated one. Must be a valid UUID format. |
athlete |
object | No | {} |
Pre-supplied athlete data used to pre-fill Athlete Info. See the athlete fields table below. |
scanType |
string | No | 'both' |
Controls which option appears on the landing screen. 'self' = Guided Scan button only (Upload Photos hidden). 'manual' = Upload Photos button only (Guided Scan hidden). 'both' = both options shown, user chooses. |
tutorial |
object | No | { enabled: true, required: true, requireEveryTime: false } |
Controls the pre-scan tutorial shown before Guided Scan and Upload Photos. See the tutorial fields table and Pre-scan Tutorial section below. |
showResults |
boolean | No | true |
Whether to show the measurements screen after the scan completes. If false, onComplete still fires with the full result payload, and then the overlay closes automatically (no action required by the user). |
units |
string | No | 'imperial' |
Display unit system for the measurements screen: 'imperial' (lbs, inches) or 'metric' (kg, cm). Does not affect the raw values in the onComplete payload — API values are always SI. |
results |
object | No | all true |
Controls which result sections are visible when showResults is true. See the results fields table below. If all three sections are disabled the empty-state screen is shown. |
theme |
object | No | dark default | Re-skins the iframe UI in your brand. All sub-keys are optional and individually overridable. See the theme fields table below. The theme is frozen at init() — to change themes between sessions, call init() again with a new theme before the next startScan(). |
width |
string | No | '1000px' |
Max width of the iframe overlay, e.g. '720px'. |
height |
string | No | '90vh' |
Max height of the iframe overlay, e.g. '85vh'. |
embedUrl |
string | No | auto-detected | Override the hosted embed base URL. Used for staging or feature-branch testing only. |
clientName |
string | No | '' |
Your organization name shown in the client TOU screen (e.g. 'FC Dallas'). Only relevant when clientTouUrl or clientPpUrl are also set. |
clientTouUrl |
string | No | '' |
URL for your Terms of Use. When provided, a second TOU screen is shown after the Fit:Match one. Can be used independently of clientPpUrl. |
clientPpUrl |
string | No | '' |
URL for your Privacy Policy. When provided, a second TOU screen is shown after the Fit:Match one. Can be used independently of clientTouUrl. |
athlete fields| Field | Type | Description |
|---|---|---|
firstName |
string | Athlete’s first name. Leading/trailing spaces are trimmed. |
lastName |
string | Athlete’s last name. Leading/trailing spaces are trimmed. |
gender |
string | Case-insensitive: 'male' | 'female' | 'other'. |
dob |
string | Date of birth in strict YYYY-MM-DD format (must be a real calendar date). |
heightCm |
number | Height in centimeters (e.g. 170). Converted to ft/in internally. Use instead of heightFt/In. |
heightFt |
number | Height — feet component (e.g. 5). Use if heightCm is omitted. |
heightIn |
number | Height — inches component (e.g. 7). Defaults to 0 if omitted. |
weight |
number | Body weight as a number; combined with weightUnit, then normalized to imperial for the API. |
weightUnit |
string | Input hint: 'imperial' (lbs) or 'metric' (kg). Defaults to 'imperial'. |
tutorial fields| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Shows or hides the tutorial screen and tutorial buttons entirely. Set to false to disable the tutorial feature completely. |
required |
boolean | true |
When true, first-time users must watch the tutorial before entering a scan. When false, the tutorial is bypassed on scan start (users can still watch via the tutorial button). |
requireEveryTime |
boolean | false |
When true, the localStorage watched flag is ignored and the tutorial is shown before every scan. Takes precedence over required. |
results fieldsControls which sections appear in the measurements screen. All default to true. Omitting the results option entirely is the same as passing all true.
| Field | Type | Default | Description |
|---|---|---|---|
showPhv |
boolean | true |
Show the Body Maturation section (PHV gauge). Only rendered when the athlete is ≤ 18, gender is known, and the API returned a PHV value — so hiding it is only relevant for those athletes. |
showBri |
boolean | true |
Show the Body Insights section (BRI and Waist-to-Height Ratio gauges). |
showAnthropometrics |
boolean | true |
Show the Anthropometrics section (Full Body, Circumferences, and Breadth collapsible tables). |
If all three are set to
false, the measurements screen shows the “scan uploaded” empty-state, butonCompletestill receives the full measurement payload.
theme fieldsEach field is optional. Any field you omit falls back to the default — so passing theme: { primaryColor: '#e30613' } only changes the primary color, nothing else. Invalid values are silently dropped, so malformed colors never break a scan.
| Field | Type | Default | Description |
|---|---|---|---|
backgroundColor |
CSS color | #000 |
Page background. |
surfaceCardColor |
CSS color | #1c1c1e |
Card-shaped panels on the measurements screen and scan flow (for example status messages). Nested rows tint automatically. |
onSurfaceCardColor |
CSS color | #ffffff |
Copy on surfaceCardColor (measurements section titles, BRI/WHtR cards, Anthropometrics rows). Separate from textColor — use when the page uses one ink color but the surfaced cards still use surfaceCardColor (often darker). |
popupBackgroundColor |
CSS color | #333333 |
Centered dialogs (alerts, exit confirmation, Manual Scan, …). Pair with popupTextColor. |
popupTextColor |
CSS color | #ffffff |
Text on popupBackgroundColor. |
scanTileBackgroundColor |
CSS color | #222222 |
Tile background on the landing screen (Guided Scan / Upload Photos), upload grid empty slots, and the Manual Scan pose strip. |
onScanTileColor |
CSS color | #a8a8a8 |
Ink on those tiles: landing icons and labels, pose silhouettes, and Front / Back / Left / Right labels. Pair with scanTileBackgroundColor. |
primaryColor |
CSS color | #eeeeee |
Primary action buttons (Continue, Upload, OK, exit dialog Cancel, …). |
onPrimaryColor |
CSS color | #1a1a1a |
Text/icons on primaryColor. |
secondaryColor |
CSS color | #a78bfa |
Secondary actions (e.g. Select Photos on Manual Scan, exit dialog Exit). |
onSecondaryColor |
CSS color | #ffffff |
Text/icons on secondaryColor. |
textColor |
CSS color | #ffffff |
Main text on the scan flow, headers outside the surfaced cards (e.g. measurements Scan Results title), and viewer chrome tied to backgroundColor. Muted helpers use ~60% opacity. |
avatarColor |
CSS color | OBJ default | Tint for the 3D avatar mesh in the post-scan viewer. Accepts any valid CSS color (#e30613, rgb(...), …). Omit to keep the generated model’s default material. |
fontFamily |
CSS font stack | system stack | Valid CSS font-family. Fonts from the parent page do not apply inside the iframe — use web-safe / system stacks. |
buttonRadius |
CSS length | 12px |
Primary button corner radius. |
Pick contrasting pairs for textColor / backgroundColor, surfaceCardColor / onSurfaceCardColor, scanTileBackgroundColor / onScanTileColor, popup* together, and primary/secondary button pairs; the SDK does not enforce contrast. Gauges, status strips, validation messaging, camera focus highlight, and the loader use fixed colors. The Powered by Fit:Match footer is not themed.
Example: a few preset themes
// "Brand red" — most common pattern for sports clubs.
QuadraScan.init({
apiKey: '...', teamId: '...',
theme: {
backgroundColor: '#0b1320',
surfaceCardColor: '#141d2f',
onSurfaceCardColor: '#ffffff',
popupBackgroundColor: '#1f2533',
popupTextColor: '#efefef',
scanTileBackgroundColor: '#141d2f',
onScanTileColor: '#c8c8c8',
primaryColor: '#e30613',
onPrimaryColor: '#ffffff',
secondaryColor: '#465064',
onSecondaryColor: '#f6f6f6',
textColor: '#ffffff',
buttonRadius: '10px',
},
});
// "Light mode" — works for daytime-leaning brand palettes.
QuadraScan.init({
apiKey: '...', teamId: '...',
theme: {
backgroundColor: '#ffffff',
surfaceCardColor: '#f4f5f7',
onSurfaceCardColor: '#0f172a',
popupBackgroundColor: '#fafafa',
popupTextColor: '#111111',
scanTileBackgroundColor: '#e8eaf0',
onScanTileColor: '#64748b',
primaryColor: '#0f172a',
onPrimaryColor: '#ffffff',
secondaryColor: '#e8eaf0',
onSecondaryColor: '#0f172a',
textColor: '#0f172a',
buttonRadius: '8px',
},
});
// "Just tweak the buttons" — keep defaults for everything else.
QuadraScan.init({
apiKey: '...', teamId: '...',
theme: {
primaryColor: '#5b5bd6',
onPrimaryColor: '#ffffff',
buttonRadius: '999px', // pill buttons
},
});
The tutorial is a full-screen instructional screen shown before the user enters Guided Scan or Upload Photos. It contains a video (Guided Scan) or a static image (Upload Photos) with a brief description and a Continue button.
A Tutorial button appears on the scan options screen at all times (unless enabled: false) so users can rewatch whenever they want.
The watched flag lives in localStorage so it survives closing the tab. In the embed, the key is scoped by parent origin (e.g. __qs_tutorial_watched:https://your-site.com) so different embedding sites do not share the same flag.
startScan() Options| Option | Type | Required | Description |
|---|---|---|---|
onComplete |
function | Yes | Called with the result payload when processing finishes successfully. If showResults is true (default), the overlay stays open on the measurements screen until the user dismisses it. If showResults is false, onComplete still fires with the payload, then the overlay closes automatically. |
onError |
function | No | Called with an error message string if something goes wrong (invalid key, network failure, etc.). The overlay remains open so the user can see the error inside the scan experience. |
onCancel |
function | No | Called when the user leaves without completing (parent backdrop click, Exit in the iframe, or QuadraScan.close()). Before upload, Exit shows a confirmation dialog; after a successful upload, Exit closes immediately. Not fired if the scan already completed (onComplete ran). |
onScanStart |
function | No | Called immediately when the scan overlay is opened, before the iframe loads. |
onPhotoCapture |
function | No | Called each time a photo is confirmed, with { pose } where pose is 'front', 'back', 'left', or 'right'. |
onUpload |
function | No | Called when the user clicks the upload button, before the HTTP request completes. No arguments. |
onClose |
function | No | Called every time the overlay is fully removed, regardless of how it closed (complete, cancel, or error). |
close() MethodQuadraScan.close();
Programmatically closes the overlay. Fires onCancel (if the scan was not already completed) followed by onClose.
Inside the iframe, the top-right Exit control uses the same confirmation rules as above (skipped once the scan has been uploaded to Reflect).
onComplete){
scanId: "464f27fb-e5d6-46ed-87c7-19b56c86136c",
athlete: {
age: 15, // number, derived from date of birth entered during scan
gender: "female", // string
height: "5' 7\"", // pre-formatted imperial string (ft' in")
weight: "100 lb" // pre-formatted string — unit is "lb"
},
measurements: {
// Nested measurement categories — see structure below.
breadth_measurements: { display_name, measurements: { ... } },
full_body_measurements: { display_name, measurements: { ... } },
lower_body_circumferences: { display_name, measurements: { ... } },
upper_body_circumferences: { display_name, measurements: { ... } },
user_inputs: { display_name, measurements: { ... } }
}
}
Each measurement entry inside a category follows this shape:
{
display_name: "Chest", // human-readable label
value: 0.9918, // numeric value
unit: "m" // unit string — see table below
}
The onComplete payload always contains raw API values in SI units, regardless of the units display option. The units option only affects what is rendered inside the iframe measurements screen.
unit value |
Meaning |
|---|---|
"m" |
Meters — multiply by 39.3701 for inches, or by 100 for cm |
"kg" |
Kilograms — multiply by 2.20462 for lbs |
"lb" / "lbs" |
Pounds (user input) |
"ft" |
Feet (user input) |
"in" |
Inches (user input) |
"yr" |
Years (e.g. estimated age at Peak Height Velocity) |
"" |
Unitless ratio or index (e.g. BMI, BRI, WHtR) |
All circumference and breadth values are in meters. Example helper that respects an imperial/metric preference:
function formatMeasurement(entry, units = 'imperial') {
if (!entry || entry.value == null) return '—';
const { value, unit } = entry;
const metric = units === 'metric';
if (unit === 'm') return metric ? `${(value * 100).toFixed(1)} cm` : `${(value * 39.3701).toFixed(1)}"`;
if (unit === 'kg') return metric ? `${value.toFixed(1)} kg` : `${(value * 2.20462).toFixed(1)} lbs`;
if (unit === 'lb' || unit === 'lbs')
return metric ? `${(value / 2.20462).toFixed(1)} kg` : `${value} lbs`;
return value + (unit ? ' ' + unit : '');
}
const m = data.measurements;
const chest = m['upper_body_circumferences']?.measurements?.['chest_circumference'];
const waist = m['upper_body_circumferences']?.measurements?.['narrowest_waist_circumference'];
const hips = m['lower_body_circumferences']?.measurements?.['hip_circumference'];
console.log('Chest:', formatMeasurement(chest, 'imperial')); // e.g. "39.1\""
console.log('Chest:', formatMeasurement(chest, 'metric')); // e.g. "99.3 cm"
| Label | Category | Key |
|---|---|---|
| Chest | upper_body_circumferences |
chest_circumference |
| Waist (narrowest) | upper_body_circumferences |
narrowest_waist_circumference |
| Low Waist | upper_body_circumferences |
waist_circumference |
| Upper Arm (L) | upper_body_circumferences |
upper_arm_circumference_left |
| Forearm (L) | upper_body_circumferences |
forearm_circumference_left |
| Hip | lower_body_circumferences |
hip_circumference |
| Upper Thigh (L) | lower_body_circumferences |
upper_thigh_circumference_left |
| Calf (L) | lower_body_circumferences |
calf_circumference_left |
| Leg Length (L) | full_body_measurements |
leg_length_left |
| BMI | full_body_measurements |
bmi |
| BRI | full_body_measurements |
bri |
| Waist-to-Height Ratio | full_body_measurements |
waist_to_height_ratio |
| Lean Mass | full_body_measurements |
lean_mass |
| Total Fat Mass | full_body_measurements |
total_fat |
| Est. Age at PHV | full_body_measurements |
phv |
The recommended approach for mobile apps is not to load quadrascan.fitmatch.ai directly in a WebView. Instead:
https://app.yoursite.com/scan). This page loads the QuadraScan SDK like any normal web integration.onComplete callback using the platform’s JavaScript bridge.This approach is correct because:
app.yoursite.com will pass origin validation automatically.onComplete as a JavaScript object, which you then forward to native code.Register a WKScriptMessageHandler in your WKWebViewConfiguration, then call it from onComplete:
// On your integration page
QuadraScan.startScan({
onComplete(data) {
// Send the full result payload to native Swift/ObjC code
window.webkit.messageHandlers.scanComplete.postMessage(JSON.stringify(data));
},
onError(err) {
window.webkit.messageHandlers.scanError.postMessage(err);
},
});
// In your WKScriptMessageHandler
func userContentController(_ ucc: WKUserContentController,
didReceive message: WKScriptMessage) {
if message.name == "scanComplete",
let body = message.body as? String,
let data = body.data(using: .utf8) {
let result = try? JSONDecoder().decode(ScanResult.self, from: data)
// Use result in your app
}
}
Expose a @JavascriptInterface and call it from onComplete:
// On your integration page
QuadraScan.startScan({
onComplete(data) {
Android.onScanComplete(JSON.stringify(data));
},
onError(err) {
Android.onScanError(err);
},
});
// In your Activity / Fragment
webView.addJavascriptInterface(object {
@JavascriptInterface
fun onScanComplete(json: String) {
// Parse json and use result on main thread
}
@JavascriptInterface
fun onScanError(message: String) { }
}, "Android")
Declare a JavascriptChannel in your WebViewController, then call it from onComplete:
// On your integration page
QuadraScan.startScan({
onComplete(data) {
ScanBridge.postMessage(JSON.stringify(data));
},
onError(err) {
ScanBridge.postMessage(JSON.stringify({ error: err }));
},
});
// In your Flutter widget
WebViewController()
..addJavaScriptChannel(
'ScanBridge',
onMessageReceived: (JavaScriptMessage message) {
final data = jsonDecode(message.message);
// Use data in your app
},
);
| Requirement | Detail |
|---|---|
| HTTPS | Your integration page must be served over https://. Camera access is blocked on non-secure origins. |
| Camera permission | Grant camera permission to the WebView in your app before or when the user initiates a scan. |
allow="camera" |
The SDK sets this on the iframe automatically — no extra config needed on your side. |
| JavaScript enabled | Must be enabled (it is by default on all platforms). |
| Origin | The URL you point the WebView to must match the origin registered for your license key. |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My Site</title>
</head>
<body>
<button id="scan-btn">Start Body Scan</button>
<div id="result"></div>
<script src="https://quadrascan.fitmatch.ai/sdk/quadrascan.js"></script>
<script>
QuadraScan.init({
apiKey: 'YOUR_LICENSE_KEY',
teamId: 'YOUR_TEAM_ID',
club: 'FC Dallas', // optional
athleteId: 'YOUR-UUID-HERE', // optional — caller-supplied UUID
clientName: 'FC Dallas', // optional — shown in client TOU screen
clientTouUrl: 'https://fcdallas.com/terms', // optional
clientPpUrl: 'https://fcdallas.com/privacy', // optional
scanType: 'both',
units: 'imperial', // optional: 'imperial' (default) | 'metric'
results: { // optional — all true by default; omit to show everything
showPhv: true,
showBri: true,
showAnthropometrics: true,
},
tutorial: { // optional — defaults shown
enabled: true, // show tutorial + tutorial buttons
required: true, // first-time users must watch
requireEveryTime: false, // skip for returning users via localStorage
},
});
document.getElementById('scan-btn').addEventListener('click', () => {
QuadraScan.startScan({
onScanStart() {
document.getElementById('scan-btn').disabled = true;
},
onPhotoCapture({ pose }) {
console.log('Photo accepted:', pose); // 'front' | 'back' | 'left' | 'right'
},
onUpload() {
console.log('Upload started by user.');
},
onComplete(data) {
const m = data.measurements;
// The payload always contains raw SI values. Format for display yourself,
// or mirror the units option you passed to init().
function fmt(category, key, units = 'imperial') {
const e = m[category]?.measurements?.[key];
if (!e || e.value == null) return '—';
const metric = units === 'metric';
if (e.unit === 'm') return metric ? `${(e.value * 100).toFixed(1)} cm` : `${(e.value * 39.3701).toFixed(1)}"`;
if (e.unit === 'kg') return metric ? `${e.value.toFixed(1)} kg` : `${(e.value * 2.20462).toFixed(1)} lbs`;
return e.value + (e.unit ? ' ' + e.unit : '');
}
document.getElementById('result').innerHTML = `
<p>Scan ID: ${data.scanId}</p>
<p>Age: ${data.athlete.age} | Gender: ${data.athlete.gender}</p>
<p>Height: ${data.athlete.height} | Weight: ${data.athlete.weight}</p>
<p>Chest: ${fmt('upper_body_circumferences', 'chest_circumference')}</p>
<p>Waist: ${fmt('upper_body_circumferences', 'narrowest_waist_circumference')}</p>
<p>Hips: ${fmt('lower_body_circumferences', 'hip_circumference')}</p>
`;
},
onError(err) {
alert('Scan failed: ' + err);
},
onCancel() {
// Fires only if the scan was not completed.
console.log('Scan cancelled by user.');
},
onClose() {
// Always fires — re-enable the button regardless of outcome.
document.getElementById('scan-btn').disabled = false;
},
});
});
</script>
</body>
</html>
QUADRASCAN_API_KEY, REFLECT_API_KEY) are server-side only and never sent to the client.allow="camera; microphone" — the SDK sets this automatically. No other permissions are needed.| Symptom | Likely cause | Fix |
|---|---|---|
| Overlay opens but shows “Unable to start scan” | Invalid or missing license key | Check apiKey matches the key registered for your domain |
| HTTP 403 on scan API calls | Origin mismatch | Ensure the domain in your license key registration exactly matches window.location.origin of the embedding page |
| Camera not working | Page not served over HTTPS, or permission denied | Serve over HTTPS; ensure the user allows camera in the browser prompt |
onComplete fires but measurements are all null |
Accessing wrong path in data.measurements |
Use the category + key paths from the table above — values are nested, not flat |
onComplete never fires — overlay shows “Scan Uploaded” |
Reflect completed the scan but returned no measurements | Verify the athlete’s dateOfBirth and gender are correct, and that the teamId matches the team the athlete is registered under. The user can hit “Start New Scan” to retry without closing the overlay. |
| Measurements screen is blank / shows “Scan Uploaded” but scan succeeded | All result sections disabled via results config, or enabled sections have nothing to render (e.g. PHV hidden for athletes over 18) |
Expected when hiding UI — onComplete still receives the full payload. To show measurements in the iframe, enable at least one section that applies to the athlete, or omit the results option entirely. |
Measurements screen still shows imperial values despite units: 'metric' |
units option not passed to init() |
Ensure units: 'metric' is set in the QuadraScan.init() call before startScan(). Note: the onComplete payload always contains raw SI values regardless of units. |
| Theme isn’t applied — iframe shows defaults | Theme passed to startScan() instead of init(), or all values were invalid |
The theme option lives on init(), not startScan(). Invalid color/length values are dropped silently — verify each value with CSS.supports('color', '<your value>') (or CSS.supports('border-radius', …) for buttonRadius). |
| Theme applied but my web font doesn’t load | Web fonts loaded on the parent page do not propagate into the iframe | Choose a system font stack for fontFamily (e.g. "-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif"), or use a font your end users are likely to have installed. |
| Button text is unreadable on the primary color | onPrimaryColor doesn’t contrast with primaryColor |
The SDK does not enforce contrast. Pair a dark primaryColor with onPrimaryColor: '#ffffff', or a light primaryColor with onPrimaryColor: '#1a1a1a'. |
| Measurements card copy is unreadable (dark-on-dark inside BRI/WHtR cards) | textColor matches the sticky header/page, but surfaceCardColor is still a dark inset panel |
Pass onSurfaceCardColor for copy that sits on those panels (#ffffff on dark teal, #0f172a on light gray #f4f5f7, …). |
| Second scan fails with missing license key | Navigating away from the iframe before sessionStorage is written |
Ensure you are on a supported browser; this is handled automatically by the SDK |
| Tutorial keeps showing for returning users | localStorage cleared or blocked (e.g. private browsing) |
Expected — private/incognito mode does not persist localStorage. Use requireEveryTime: true where this matters. |
Tutorial never shows despite required: true |
The watched flag was set in a previous session | Clear the flag from the browser console: Object.keys(localStorage).filter(k => k.startsWith('__qs_tutorial_watched')).forEach(k => localStorage.removeItem(k)) |
| Tutorial button not visible | enabled: false was passed to init() |
Set tutorial: { enabled: true } or omit the tutorial option entirely |