Adding a size recommendation widget to a Shopify or WooCommerce store is a two-layer problem: the presentation layer (the widget the shopper sees) and the data layer (calling the prediction API, storing results, applying recommendations). Both platforms have different extension mechanisms, but the architecture is the same: a server-side proxy handles the API call, and the storefront JS reads from that proxy. The API key never touches the browser.
Architecture overview
Shopper browser
→ POST /apps/sizing/recommend (your proxy — Shopify app or WP plugin endpoint)
→ POST /v1/predict (DimensionsPot API, server-side)
← predicted dimensions + size recommendation
← JSON { recommended_size, confidence, warnings }
→ stored in Shopify customer metafield or WooCommerce user meta
The proxy also handles:
- Input validation and unit normalization (height cm→mm)
- Rate limiting per shop/user
- Caching repeated requests (same height + weight → same prediction)
- Applying the shop’s size charts to the predicted dimensions
Shopify: app proxy or custom app endpoint
Shopify has two main extension models for server-side logic: Shopify App Proxy (routes /apps/<slug>/* to your server) and Shopify Functions + App Bridge (for checkout extensions). For a sizing widget on the product page, the App Proxy is the right choice.
Node.js proxy endpoint
// sizing-proxy.js — Express handler for the Shopify App Proxy route
const express = require("express");
const router = express.Router();
const RAPIDAPI_KEY = process.env.RAPIDAPI_KEY;
const SIZE_CHARTS = require("./size-charts"); // Your product size chart data
router.post("/recommend", async (req, res) => {
const { height_cm, weight_kg, gender, product_type, region = "GLOBAL" } = req.body;
// Validate inputs
const errors = validateMeasurementInputs({ height_cm, weight_kg, gender });
if (errors.length > 0) {
return res.status(400).json({ errors });
}
const height_mm = Math.round(parseFloat(height_cm) * 10);
const weight = parseFloat(weight_kg);
try {
// Call prediction API server-side (API key stays here)
const apiResponse = await fetch(
"https://dimensionspot-bodysize-engine.p.rapidapi.com/v1/predict",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-RapidAPI-Key": RAPIDAPI_KEY,
"X-RapidAPI-Host": "dimensionspot-bodysize-engine.p.rapidapi.com"
},
body: JSON.stringify({
input_data: {
input_unit_system: "metric",
subject: {
gender: gender.toLowerCase(),
input_origin_region: region
},
anchors: {
body_height: height_mm, // mm!
body_mass: weight
}
},
output_settings: {
calculation: { target_region: region, body_build_type: "CIVILIAN" },
requested_dimensions: { bundle: "TORSO" },
output_format: { include_range_95: true, confidence_score_threshold: 60 }
}
})
}
);
if (!apiResponse.ok) {
const error = await apiResponse.text();
console.error("Prediction API error:", error);
return res.status(502).json({ error: "Size prediction temporarily unavailable" });
}
const data = await apiResponse.json();
const dimensions = extractDimensions(data.body_dimensions || {});
// Apply size chart
const sizeChart = SIZE_CHARTS[product_type] || SIZE_CHARTS.default;
const recommendation = recommendSize(dimensions, sizeChart);
res.json({
recommended_size: recommendation.size,
confidence: recommendation.confidence,
dimensions_cm: Object.fromEntries(
Object.entries(dimensions).map(([k, v]) => [k, Math.round(v / 10 * 10) / 10])
),
warnings: recommendation.warnings
});
} catch (err) {
console.error("Sizing proxy error:", err);
res.status(500).json({ error: "Size prediction failed" });
}
});
function validateMeasurementInputs({ height_cm, weight_kg, gender }) {
const errors = [];
const h = parseFloat(height_cm);
const w = parseFloat(weight_kg);
if (isNaN(h) || h < 100 || h > 250)
errors.push("Height must be between 100 and 250 cm.");
if (isNaN(w) || w < 30 || w > 300)
errors.push("Weight must be between 30 and 300 kg.");
if (!["male", "female"].includes(gender?.toLowerCase()))
errors.push("Gender must be 'male' or 'female'.");
return errors;
}
function extractDimensions(bodyDimensions) {
const result = {};
for (const [dimId, d] of Object.entries(bodyDimensions)) {
if (d.value != null) {
result[dimId] = d.value;
}
}
return result;
}
module.exports = router;
Shopify Liquid template integration
On the product page, load the sizing widget and connect it to the proxy:
{% comment %} In your product.liquid or a section file {% endcomment %}
<div id="sizing-widget" data-product-type="{{ product.type | downcase | replace: ' ', '_' }}">
<button id="sizing-trigger" class="btn btn-secondary">Find My Size</button>
<div id="sizing-modal" style="display:none;">
<h3>Get Your Size</h3>
<div class="sizing-field">
<label for="sizing-height">Height</label>
<input type="number" id="sizing-height" placeholder="175" min="100" max="250">
<span class="unit-label">cm</span>
</div>
<div class="sizing-field">
<label for="sizing-weight">Weight</label>
<input type="number" id="sizing-weight" placeholder="70" min="30" max="300">
<span class="unit-label">kg</span>
</div>
<div class="sizing-field">
<label for="sizing-gender">Gender</label>
<select id="sizing-gender">
<option value="">Select...</option>
<option value="female">Female</option>
<option value="male">Male</option>
</select>
</div>
<button id="sizing-submit">Get Recommendation</button>
<div id="sizing-result" style="display:none;"></div>
</div>
</div>
<script>
(function() {
const widget = document.getElementById("sizing-widget");
const productType = widget.dataset.productType;
document.getElementById("sizing-trigger").addEventListener("click", () => {
document.getElementById("sizing-modal").style.display = "block";
});
document.getElementById("sizing-submit").addEventListener("click", async () => {
const height = document.getElementById("sizing-height").value;
const weight = document.getElementById("sizing-weight").value;
const gender = document.getElementById("sizing-gender").value;
const resultEl = document.getElementById("sizing-result");
resultEl.textContent = "Calculating...";
resultEl.style.display = "block";
try {
// Proxy route — no API key in browser
const res = await fetch("/apps/sizing/recommend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
height_cm: height,
weight_kg: weight,
gender: gender,
product_type: productType
})
});
const data = await res.json();
if (!res.ok) {
resultEl.textContent = data.errors?.join(" ") || "Could not determine size.";
return;
}
resultEl.innerHTML = `
<strong>Recommended size: ${data.recommended_size}</strong>
${data.warnings?.length ? `<p class="sizing-warning">${data.warnings.join(" ")}</p>` : ""}
`;
// Optionally pre-select the variant
preselectVariant(data.recommended_size);
} catch (e) {
resultEl.textContent = "Size recommendation unavailable.";
}
});
function preselectVariant(sizeLabel) {
// Find the size option and trigger Shopify's variant change
const sizeSelector = document.querySelector('select[name="Size"], select[data-option="Size"]');
if (!sizeSelector) return;
const options = Array.from(sizeSelector.options);
const match = options.find(opt => opt.text.trim() === sizeLabel);
if (match) {
sizeSelector.value = match.value;
sizeSelector.dispatchEvent(new Event("change", { bubbles: true }));
}
}
})();
</script>
Storing the size profile in Shopify customer metafields
// After recommendation, persist to Shopify customer metafield
// (requires Storefront API with customer token)
async function saveCustomerSizeProfile(customerAccessToken, gender, heightCm, weightKg, recommendedSize) {
const mutation = `
mutation customerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
customer { id }
userErrors { field message }
}
}
`;
// Note: direct metafield writes require Admin API —
// this pattern requires your proxy to handle the metafield write
await fetch("/apps/sizing/save-profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Customer-Token": customerAccessToken
},
body: JSON.stringify({
gender, height_cm: heightCm, weight_kg: weightKg,
recommended_sizes: { [getCurrentProductType()]: recommendedSize }
})
});
}
WooCommerce: PHP plugin
WooCommerce extends well via action/filter hooks. The plugin adds a sizing widget to product pages and exposes a REST endpoint for the API call.
Plugin structure
wp-content/plugins/wc-sizing-advisor/
├── wc-sizing-advisor.php (main plugin file)
├── includes/
│ ├── class-sizing-api.php (DimensionsPot API client)
│ ├── class-sizing-rest.php (REST endpoint registration)
│ └── class-sizing-widget.php (product page widget output)
└── assets/
└── sizing-widget.js (frontend JS)
Main plugin file
<?php
/**
* Plugin Name: WC Sizing Advisor
* Description: Body measurement size recommendations for WooCommerce products.
* Version: 1.0.0
*/
defined('ABSPATH') || exit;
define('WC_SIZING_VERSION', '1.0.0');
define('WC_SIZING_API_KEY', get_option('wc_sizing_rapidapi_key', ''));
require_once __DIR__ . '/includes/class-sizing-api.php';
require_once __DIR__ . '/includes/class-sizing-rest.php';
require_once __DIR__ . '/includes/class-sizing-widget.php';
add_action('rest_api_init', ['WC_Sizing_Rest', 'register_routes']);
add_action('woocommerce_single_product_summary', ['WC_Sizing_Widget', 'render'], 25);
add_action('wp_enqueue_scripts', function() {
if (is_product()) {
wp_enqueue_script(
'wc-sizing-widget',
plugin_dir_url(__FILE__) . 'assets/sizing-widget.js',
['jquery'], WC_SIZING_VERSION, true
);
wp_localize_script('wc-sizing-widget', 'wcSizing', [
'restUrl' => rest_url('wc-sizing/v1/'),
'nonce' => wp_create_nonce('wp_rest')
]);
}
});
REST endpoint
<?php
// includes/class-sizing-rest.php
class WC_Sizing_Rest {
public static function register_routes() {
register_rest_route('wc-sizing/v1', '/recommend', [
'methods' => 'POST',
'callback' => [self::class, 'recommend'],
'permission_callback' => '__return_true',
'args' => [
'height_cm' => ['required' => true, 'type' => 'number'],
'weight_kg' => ['required' => true, 'type' => 'number'],
'gender' => ['required' => true, 'type' => 'string'],
'product_id' => ['required' => false, 'type' => 'integer'],
'region' => ['required' => false, 'type' => 'string', 'default' => 'GLOBAL'],
]
]);
}
public static function recommend(WP_REST_Request $request) {
$height_cm = floatval($request->get_param('height_cm'));
$weight_kg = floatval($request->get_param('weight_kg'));
$gender = sanitize_text_field($request->get_param('gender'));
$product_id = intval($request->get_param('product_id'));
$region = sanitize_text_field($request->get_param('region'));
// Validate
$errors = self::validate_inputs($height_cm, $weight_kg, $gender);
if (!empty($errors)) {
return new WP_Error('validation_failed', implode(' ', $errors), ['status' => 400]);
}
// Normalize gender
$gender_normalized = strtolower($gender);
if (!in_array($gender_normalized, ['male', 'female'])) {
return new WP_Error('invalid_gender', 'Gender must be male or female.', ['status' => 400]);
}
// Call API server-side
$api = new WC_Sizing_Api();
$dimensions = $api->predict($height_cm, $weight_kg, $gender_normalized, $region);
if (is_wp_error($dimensions)) {
return $dimensions;
}
// Get product size chart
$size_chart = self::get_product_size_chart($product_id, $gender_normalized);
$recommendation = self::apply_size_chart($dimensions, $size_chart);
// Save to user meta if logged in
if (is_user_logged_in()) {
$user_id = get_current_user_id();
update_user_meta($user_id, '_sizing_profile', [
'gender' => $gender_normalized,
'height_cm' => $height_cm,
'weight_kg' => $weight_kg,
'region' => $region,
'updated_at' => current_time('mysql')
]);
}
return rest_ensure_response([
'recommended_size' => $recommendation['size'],
'confidence' => $recommendation['confidence'],
'dimensions_cm' => array_map(fn($v) => round($v / 10, 1), $dimensions),
'warnings' => $recommendation['warnings']
]);
}
private static function validate_inputs($height_cm, $weight_kg, $gender): array {
$errors = [];
if ($height_cm < 100 || $height_cm > 250) {
$errors[] = 'Height must be between 100 and 250 cm.';
}
if ($weight_kg < 30 || $weight_kg > 300) {
$errors[] = 'Weight must be between 30 and 300 kg.';
}
return $errors;
}
private static function get_product_size_chart(int $product_id, string $gender): array {
// Try product-specific chart first, fall back to category chart
$chart = get_post_meta($product_id, '_size_chart', true);
if (!$chart) {
$product = wc_get_product($product_id);
$categories = $product ? wp_get_post_terms($product_id, 'product_cat', ['fields' => 'slugs']) : [];
foreach ($categories as $cat) {
$chart = get_option("wc_sizing_chart_{$cat}_{$gender}");
if ($chart) break;
}
}
return $chart ?: self::get_default_chart($gender);
}
private static function get_default_chart(string $gender): array {
// Fallback: generic European unisex tops chart (chest circumference in mm)
return [
'XS' => ['chest_min' => 780, 'chest_max' => 860],
'S' => ['chest_min' => 860, 'chest_max' => 920],
'M' => ['chest_min' => 920, 'chest_max' => 990],
'L' => ['chest_min' => 990, 'chest_max' => 1060],
'XL' => ['chest_min' => 1060, 'chest_max' => 1130],
'XXL'=> ['chest_min' => 1130, 'chest_max' => 1220],
];
}
private static function apply_size_chart(array $dimensions_mm, array $size_chart): array {
$chest = $dimensions_mm['chest_circumference'] ?? null;
if (!$chest) {
return ['size' => null, 'confidence' => 0, 'warnings' => ['Could not determine chest circumference.']];
}
foreach ($size_chart as $label => $range) {
if ($chest >= $range['chest_min'] && $chest < $range['chest_max']) {
return ['size' => $label, 'confidence' => 0.85, 'warnings' => []];
}
}
return ['size' => null, 'confidence' => 0, 'warnings' => ['Measurements fall outside available sizes.']];
}
}
API client class
<?php
// includes/class-sizing-api.php
class WC_Sizing_Api {
private string $api_key;
private string $base_url = 'https://dimensionspot-bodysize-engine.p.rapidapi.com/v1/predict';
public function __construct() {
$this->api_key = get_option('wc_sizing_rapidapi_key', '');
}
public function predict(
float $height_cm,
float $weight_kg,
string $gender,
string $region = 'GLOBAL'
): array|WP_Error {
if (empty($this->api_key)) {
return new WP_Error('no_api_key', 'Sizing API key not configured.', ['status' => 500]);
}
$height_mm = intval($height_cm * 10); // cm → mm
$payload = [
'input_data' => [
'input_unit_system' => 'metric',
'subject' => [
'gender' => $gender,
'input_origin_region' => $region
],
'anchors' => [
'body_height' => $height_mm,
'body_mass' => $weight_kg
]
],
'output_settings' => [
'calculation' => [
'target_region' => $region,
'body_build_type' => 'CIVILIAN'
],
'requested_dimensions' => ['bundle' => 'TORSO'],
'output_format' => [
'include_range_95' => false,
'confidence_score_threshold' => 60
]
]
];
$response = wp_remote_post($this->base_url, [
'headers' => [
'Content-Type' => 'application/json',
'X-RapidAPI-Key' => $this->api_key,
'X-RapidAPI-Host' => 'dimensionspot-bodysize-engine.p.rapidapi.com'
],
'body' => wp_json_encode($payload),
'timeout' => 10
]);
if (is_wp_error($response)) {
return new WP_Error('api_request_failed', $response->get_error_message(), ['status' => 502]);
}
$status = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($status !== 200) {
error_log("WC Sizing API error {$status}: " . wp_json_encode($body));
return new WP_Error('api_error', 'Size prediction failed.', ['status' => 502]);
}
$dimensions = [];
foreach ($body['body_dimensions'] ?? [] as $dim_id => $dim) {
if (isset($dim['value'])) {
$dimensions[$dim_id] = $dim['value'];
}
}
return $dimensions;
}
}
Frontend widget JS (WooCommerce)
// assets/sizing-widget.js
jQuery(function($) {
$("#wc-sizing-submit").on("click", function() {
const height = $("#wc-sizing-height").val();
const weight = $("#wc-sizing-weight").val();
const gender = $("#wc-sizing-gender").val();
const productId = $("#wc-sizing-widget").data("product-id");
const resultEl = $("#wc-sizing-result");
resultEl.text("Calculating...").show();
$.ajax({
url: wcSizing.restUrl + "recommend",
method: "POST",
beforeSend: function(xhr) {
xhr.setRequestHeader("X-WP-Nonce", wcSizing.nonce);
},
contentType: "application/json",
data: JSON.stringify({
height_cm: parseFloat(height),
weight_kg: parseFloat(weight),
gender: gender,
product_id: parseInt(productId)
}),
success: function(data) {
let html = `<strong>Recommended size: ${data.recommended_size || "—"}</strong>`;
if (data.warnings && data.warnings.length) {
html += `<p class="sizing-warning">${data.warnings.join(" ")}</p>`;
}
resultEl.html(html);
// Pre-select the size in the variation dropdown
if (data.recommended_size) {
$('select[data-attribute_name="attribute_pa_size"], select[name="attribute_pa_size"]')
.find(`option:contains("${data.recommended_size}")`)
.first()
.prop("selected", true)
.end()
.trigger("change");
}
},
error: function(xhr) {
const msg = xhr.responseJSON?.message || "Size recommendation unavailable.";
resultEl.text(msg);
}
});
});
});
Caching to reduce API calls
Both integrations benefit from a simple cache keyed on (height_cm, weight_kg, gender, region) — the same inputs always produce the same prediction:
// Node.js proxy with Redis cache
const redis = require("redis");
const client = redis.createClient();
async function getPredictionCached(height_mm, weight_kg, gender, region) {
const key = `sizing:${height_mm}:${weight_kg}:${gender}:${region}`;
const cached = await client.get(key);
if (cached) return JSON.parse(cached);
const prediction = await fetchFromPredictionApi(height_mm, weight_kg, gender, region);
await client.setEx(key, 86400, JSON.stringify(prediction)); // 24h TTL
return prediction;
}
// WooCommerce: transient cache
function get_cached_prediction(float $height_mm, float $weight_kg, string $gender, string $region): array|false {
$key = 'wc_sizing_' . md5("{$height_mm}_{$weight_kg}_{$gender}_{$region}");
return get_transient($key);
}
function set_cached_prediction(float $height_mm, float $weight_kg, string $gender, string $region, array $data): void {
$key = 'wc_sizing_' . md5("{$height_mm}_{$weight_kg}_{$gender}_{$region}");
set_transient($key, $data, DAY_IN_SECONDS);
}
Checklist before going live
- The API key is only in environment variables on the server, never in JS source.
body_heightis sent as millimeters:int(height_cm * 10).genderis lowercase"male"or"female".- The REST endpoint validates height and weight before calling the prediction API.
- Failed API calls return a user-friendly message, not a raw stack trace.
- Size recommendations are displayed with a disclaimer (“Based on your measurements — try the garment before returning if unsure”).
- The widget is tested against your actual variant naming (Shopify and WooCommerce variant names must exactly match what
preselectVariant()/.trigger("change")expects).