← Back to index | ← 08b Platform analytics | 10 Deployment →
How MMC's WordPress site and the CoachPilot gateway talk to each other. Two directions, two secret patterns, one shared protocol.
The two bridge directions
┌──────────────────────────┐ ┌──────────────────────────┐
│ MMC WordPress │ │ CoachPilot Gateway │
│ mindfulmoneycoaching │ │ api.coachpilot.ch │
│ .online │ │ │
│ │ │ │
│ │ ──→ A │ (1) Calibrate scores │
│ │ ──→ B │ (2) Sync MMC data IN │
│ │ ←── C │ (3) Webhook OUT to MMC │
│ │ │ │
└──────────────────────────┘ └──────────────────────────┘
| Direction | Endpoint pattern | Secret used | Caller |
|---|---|---|---|
| A: WP → Gateway (Likert calibration) | POST /api/v1/assess/framework/.../calibrate | — (public-readable) | WP archetype-scores.php |
| B: Gateway → WP (bio + Money Quiz pulls) | GET /wp-json/coachpilot/v1/<endpoint> | X-MMC-Bridge-Secret | Gateway _mmc_bridge_get |
| C: Quiz app → WP (archetype-scores webhook) | POST /wp-json/sg-course/v1/archetype-scores | X-Deal-Secret | moneyquiz-app fireArchetypeWebhook |
Direction A — Likert calibration (C5, 2026-05-19)
When MMC's WordPress Money Quiz (Likert) completes, the WP receiver POSTs raw scores to the gateway to apply Tier-2 calibration before storing in mq_* tables.
WP-side caller
archetype-scores.php::mmc_archetype_scores_calibrate_via_gateway:
function mmc_archetype_scores_calibrate_via_gateway( array $scores, string $tenant = 'mmc' ): ?array {
$base = get_option( 'mmc_calibrate_gateway_url', 'https://api.coachpilot.ch' );
$framework = get_option( 'mmc_calibrate_framework', 'money_archetypes' );
$tenant = get_option( 'mmc_calibrate_tenant', $tenant );
// 0..1 → 0..100
$scaled = array_map( fn($v) => round($v * 100, 4), $scores );
$url = rtrim($base, '/') . '/api/v1/assess/framework/' . rawurlencode($framework) . '/calibrate?client=' . rawurlencode($tenant);
$response = wp_remote_post( $url, [...] );
// Failure: return null → caller keeps raw scores
// Success: return calibrated 0..1 scores
}
Scale conversion: WP stores 0..1; gateway uses 0..100. Convert at the boundary.
Failure-safe: 5 s timeout, returns null on any non-200, caller falls through to raw scores. The quiz always completes.
Gateway-side endpoint
POST /api/v1/assess/framework/{framework_id}/calibrate?client=<tenant> — defined in routers/assess_framework.py. Public (no auth).
Implementation:
- Load resolved framework for the tenant (cached by Postgres view materialisation TBD; today every call hits DB).
- Call scoring_calibration.py::apply_archetype_calibration.
- Return
{scores, applied_rules, framework}.
See 06 Scoring + calibration for the rule engine.
Verification
curl -X POST "https://api.coachpilot.ch/api/v1/assess/framework/money_archetypes/calibrate?client=mmc" \
-H "Content-Type: application/json" \
-d '{"scores":{"victim":65,"martyr":22}}'
# → {"scores":{"victim":65,"martyr":32},"applied_rules":["victim_martyr_cooccurrence"],"framework":"money_archetypes"}
Production smoke 2026-05-19: raw martyr 0.22 → calibrated 0.32 stored in WP transient. End-to-end verified.
Direction B — Bio + Money Quiz data IN (gateway ← WP)
The CoachPilot dashboard's Personas system needs to read MMC's existing data:
- Student bio from the SG Course workbook (topic 20080 responses).
- Historical Money Quiz scores by email.
Gateway calls MMC WP via _mmc_bridge_get(path, params).
Auth
Pre-shared secret stored in three places:
- WP option
mmc_bridge_integration_secret - k8s
coachpilot-secrets.MMC_BRIDGE_INTEGRATION_SECRET - Gateway env var
MMC_BRIDGE_INTEGRATION_SECRET
Header on every gateway → WP call: X-MMC-Bridge-Secret: <secret>.
WP gates via mmc_bridge_integration_auth permission_callback:
function mmc_bridge_integration_auth($request) {
$expected = get_option('mmc_bridge_integration_secret');
$provided = $request->get_header('X-MMC-Bridge-Secret');
return $expected && $provided && hash_equals($expected, $provided);
}
hash_equals is constant-time — protects against timing attacks. Don't replace with ==.
Endpoints exposed by MMC
| Path | Source | Purpose |
|---|---|---|
/wp-json/coachpilot/v1/sg-course-bio?email= | MMC mu-plugin integrations-coachpilot.php | Returns bio data from _sg_workbook_responses user meta for topic 20080 |
/wp-json/coachpilot/v1/money-quiz?email= | Same | Returns historical Money Quiz scores from mq_taken + mq_results |
See reference_mmc_sg_course_workbook.md for the bio source structure.
Gateway-side helper
_mmc_bridge_get(path, params) in api-gateway/registry/framework_config.py (or wherever it now lives). Returns parsed JSON or raises on 503/502/404. Adding a new integration:
- WP:
register_rest_routewithmmc_bridge_integration_authpermission_callback. - Gateway: helper using
_mmc_bridge_get. - Dashboard:
assess.fetchXFromMmc(...)API client helper.
Secret rotation
24 h dual-validate window:
- Update WP option
mmc_bridge_integration_secretto new value. - Update k8s secret
MMC_BRIDGE_INTEGRATION_SECRETto new value. - Rolling restart gateway pods (
kubectl rollout restart deployment/coachpilot-gateway). - After 24 h with no errors, remove old secret from any temporary dual-accept code (if added).
Direction C — Archetype-scores webhook (WP ← quiz app)
The original direction this all started from. Every quiz completion (any format, any tenant with archetype_scores_webhook set) POSTs to the tenant's CRM receiver. For MMC, that receiver is WP.
Caller
moneyquiz-app/src/lib/tenant-config.ts::fireArchetypeWebhook:
export async function fireArchetypeWebhook(
brand: TenantBrand | null,
payload: ArchetypeWebhookPayload,
): Promise<void> {
if (!brand?.archetype_scores_webhook) return;
if (!payload.userEmail) return;
// ... normalise scores 0..100 → 0..1
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (brand.archetype_scores_secret) {
headers["X-Deal-Secret"] = brand.archetype_scores_secret;
}
await fetch(brand.archetype_scores_webhook, { method: "POST", headers, body: ... });
}
Fire-and-forget: errors logged, never thrown. The quiz always completes.
Called from /api/quiz/results (Mirror + Game) and /api/deal/results (Deal + Realm).
Receiver
MMC archetype-scores.php::sg_rest_receive_archetype_scores:
Receives {email, name, scores, dominant, secondary, source, response_format, played_at, ...}. Validates X-Deal-Secret. Sanitizes scores (legacy → production key map). C5 calibration. Looks up WP user. Writes to legacy mq_* tables. Schedules emails. Stamps invite_token completion. Returns {stored, user_id, snapshot}.
Payload shape (contract)
{
"email": "player@example.com",
"name": "Jane Doe",
"scores": {
"hero": 0.40, "artist": 0.35, "ruler": 0.20, "innocent": 0.25,
"maverick": 0.30, "victim": 0.65, "martyr": 0.32, "magician": 0.15
},
"dominant": "victim",
"secondary": "hero",
"source": "money-mirror",
"response_format": "qa",
"quiz_length": "",
"urgency": "guide",
"combos": [],
"played_at": "2026-05-19T08:00:00Z"
}
This contract is stable. Any new tenant CRM receiver MUST accept this shape (or document a different one in their tenant_brand metadata).
The auto-pages mu-plugin
MMC/mu-plugins/mmc-moneyquiz-bridge/auto-pages.php creates and renders the four iframe pages on MMC's WordPress:
const MMC_BRIDGE_AUTO_PAGES = [
'money-mirror' => 'Money Mirror',
'money-game' => 'Money Game',
'money-deal' => 'Money Deal',
'money-realm' => 'Money Realm',
];
// On admin_init (idempotent via option flag):
foreach (MMC_BRIDGE_AUTO_PAGES as $slug => $title) {
if (! get_page_by_path($slug)) {
wp_insert_post([
'post_title' => $title,
'post_name' => $slug,
'post_status' => 'publish',
'post_type' => 'page',
]);
}
}
// On the_content filter for those slugs: render iframe
add_filter('the_content', function($content) {
if (is_page($auto_slugs)) {
$tenant = 'mmc';
$bare = str_replace('money-', '', get_post()->post_name);
$src = "https://quiz.thesynergygroup.ch/{$bare}?tenant={$tenant}";
return '<iframe src="' . esc_url($src) . '" style="..."></iframe>';
}
return $content;
});
Page IDs created on first deploy: 21692–21695.
This is the canonical pattern for adding white-label iframe tenants. Future tenants get their own mu-plugin (or shared with namespacing).
File deploy pattern for MMC
MMC WP files deploy via SFTP to Hostinger:
- Host:
coachpilot.ch:65002 - User:
u146818668 - Theme path:
/home/u146818668/domains/mindfulmoneycoaching.online/public_html/wp-content/themes/coachpilot-theme-v2/ - Mu-plugins:
/home/u146818668/domains/mindfulmoneycoaching.online/public_html/wp-content/mu-plugins/ - SG Course Engine:
/home/u146818668/domains/mindfulmoneycoaching.online/public_html/wp-content/mu-plugins/sg-course-engine/
After SFTP upload:
- Touch the file (force timestamp update).
- Invalidate OPcache:
opcache_invalidate('/path/to/file.php', true)via wp eval. wp cache flush+ purge LiteSpeed + transients.
See 11 Operations §MMC deploy for the full recipe.
Secret store
| Secret | Where stored | Purpose |
|---|---|---|
MMC_BRIDGE_INTEGRATION_SECRET | k8s + WP option | Gateway → WP integration auth |
archetype_scores_secret (per tenant) | tenant_brand column + WP sg_deal_webhook_secret option | Quiz app → CRM webhook auth |
BEARER_MMC | k8s coachpilot-secrets | MMC tenant static bearer for gateway-internal calls |
| Hostinger SSH password | Vault secret/passwords/hostinger_ssh | SFTP for MMC + TSG + HSP sites (shared) |
Never hardcode secrets in source. The mmc-deal-webhook-2026 literal that lingers in some test fixtures is the DEFAULT VALUE — production values are in the secret store.
Common pitfalls
- Sending raw scores at 0..1 to the calibrate endpoint — calibration rules use 0..100 thresholds. Always × 100 before sending; ÷ 100 after.
- Sending
?client=without a corresponding tenant row — gateway returns 404. Add the tenant first. - Forgetting to bump WP cache after deploying
archetype-scores.php. OPcache will serve stale bytecode. - Hardcoded secret in webhook secret field — TSG/coachpilot use NULL (no webhook). Don't fill these in just because there's a field.
Next
→ 10 Deployment — image taxonomy, kubectl recipes, rollout patterns.