This guide is everything your team needs to connect the AI Hair Test to the KIWABI Concierge. There are two integration paths, meant to be shipped in order: a client-side bridge for same-visit personalization (fast, no backend), then a webhook for durability and returning visitors. Start with Section 2, the decisions we need from you, since the rest depends on those answers.
Goal: when a customer completes the AI Hair Test, the Concierge receives their result and personalizes the conversation. The quiz stays yours; the Concierge stays ours; the two connect through a small, well-defined contract.
| Tangent builds | KIWABI builds |
|---|---|
| Emit a completion signal on the page (event or postMessage) | Listen for it and pass the result into the Concierge |
| POST a signed webhook to our endpoint on each completion | Verify, store, and serve the profile to the Concierge |
| Include a join key + a consent flag in the payload | Match the submission to a Concierge session and personalize |
Answer these first; they determine the contract.
email ideally, a Shopify customer id, or a persistent first-party id that both the quiz and the Concierge can read on the page. A per-visit session_id only matches within a single visit. Please also tell us how often the quiz captures an email. (Anonymous submissions are analytics only. Same-visit personalization, Phase A, needs no identifier.)window CustomEvent, an iframe postMessage, a JS callback, or a dataLayer push? Please share the exact event name and payload shape, or adopt ours below.Both widgets live on the same KIWABI page, so the fastest win needs no backend: when the quiz finishes, hand the result straight to the Concierge in the browser. Recommended contract (Tangent emits, KIWABI listens):
// Tangent, on completion — dispatch a CustomEvent:
window.dispatchEvent(new CustomEvent('tangent:quiz_complete', { detail: {
submissionId: 'tan_9f2...',
email: 'jane@example.com', // if consented
profile: { hairType: 'wavy', concern: 'thinning', segment: 'thinning-wavy' },
recommendations: ['Root Vanish'],
quizId: 'ai_hair_test', version: '3',
completedAt: '2026-07-08T17:05:00Z'
}}));
// KIWABI, listening (already implemented on our side):
window.addEventListener('tangent:quiz_complete', (e) => {
window.KiwabiConcierge?.setContext({ source: 'ai_hair_test', ...e.detail });
});
window.parent.postMessage({type:'quiz_complete', payload:{...}}, 'https://kiwabi.com') instead, and tell us your exact iframe origin so we verify it.Content-Type: application/jsonSend one request per completed submission. Delivery is at-least-once: we deduplicate on submission_id, so retries are safe.
{
"submission_id": "tan_9f2...",
"occurred_at": "2026-07-08T17:05:00Z",
"quiz": { "id": "ai_hair_test", "version": "3" },
"identity": {
"email": "jane@example.com",
"shopify_customer_id": null,
"session_id": "sess_abc",
"visitor_id": "vis_123"
},
"answers": [
{ "id": "hair_type", "q": "Hair type?", "a": "wavy" },
{ "id": "concern", "q": "Main concern?", "a": "thinning" }
],
"derived": { "segment": "thinning-wavy", "recommendations": ["Root Vanish"] },
"consent": { "marketing": true, "at": "2026-07-08T17:04:40Z" },
"market": "US", "locale": "en-US"
}
| Field | Type | Req | Notes |
|---|---|---|---|
submission_id | string | yes | Unique per submission. Idempotency key. |
occurred_at | ISO 8601 | yes | Completion time (UTC). We order by this. |
quiz.id | string | yes | e.g. ai_hair_test. |
identity.email | string | cond. | Primary cross-session join key. Send if consented. |
identity.session_id | string | rec. | Correlates to the on-site session for same-visit matching. |
answers[] | array | yes | Each: {id, q, a}. |
derived | object | no | Segment and product recommendations if you compute them. |
consent.marketing | bool | yes | Whether the user consented to this use. |
market, locale | string | no | e.g. US, en-US. |
At least one of identity.email or identity.session_id must be present for personalization.
| Status | Meaning |
|---|---|
| 200 | {"ok": true} — accepted (processed async). |
| 401 | Missing or invalid signature. |
| 400 | Malformed JSON or missing required field. |
| 5xx | Our error. Please retry. |
On any non-2xx, retry with exponential backoff (for example 1m, 5m, 30m, 2h, 6h, then give up after ~24h). Because we dedupe on submission_id, duplicate deliveries are harmless.
Sign the exact raw request body bytes (do not re-serialize) with HMAC-SHA256 and the shared secret, hex-encode it, and send it in the header:
X-Tangent-Signature: sha256=<hex-hmac-sha256(secret, raw_body)>
We recompute and compare in constant time; a mismatch returns 401. All traffic is TLS only.
| secret | example_secret_not_for_production |
| body | {"submission_id":"tan_test_0001","occurred_at":"2026-07-08T00:00:00Z","quiz":{"id":"ai_hair_test"}} |
| signature | sha256=1418db424e2a198a08f2f0f9cd32823f9171106390d10ec8b1a035fc303e0f99 |
Same-visit matching is automatic through the Phase A bridge. For a returning visitor we match on a stable identifier (a Shopify login email, or a persistent first-party id); a per-visit session_id will not match across visits. We look up the latest submission for that identity and inject a compact profile into the Concierge's context. The Concierge personalizes from stored facts only: it never invents a hair profile, and if there is no quiz on file it simply asks. A submission with no stable identifier is kept for aggregate analytics but cannot personalize a return visit.
Phase A stores nothing new: the data stays in the customer's own browser during their own visit. Phase B introduces stored personal data, so the items below are prerequisites before any customer data flows, not afterthoughts.
consent flag must reflect the user's actual choice; we honor it.ai_hair_test submission to staging; we confirm 200 and that the profile is stored.Phase A can go live on its own for an immediate win while Phase B is built. Nothing here blocks your quiz roadmap; the contract is additive.
← back to the plain-language overview