Appearance
Portal entry handoff — design contract
Status: Active design contract. Lives in docs/architecture/. Phase A deliverable per ENG-187.
Audience: any future engineer building a new surface (mobile app, agent-on-behalf-of flow, partner-channel embed, B2B-portal lead-in) that wants to drop a member into the enrollment flow without re-collecting calculator inputs.
The constraint
The member portal lives on a different origin from the marketing site:
- Marketing apex:
askflorence.health(andwww.askflorence.health) - Portal subdomain:
app.askflorence.health
This is intentional — per ENG-280 the cookie boundary cleanly separates marketing-tier third-party trackers (GA, Meta pixel, etc.) from PHI-tier portal traffic. CSP on portal architecturally blocks third-party scripts. The marketing/portal split applies even though both surfaces share branding and the user experiences them as one product.
Browser physics: a cookie set on askflorence.health does NOT follow the user when they navigate to app.askflorence.health (different origin). sessionStorage and localStorage are also origin-scoped — they do not cross subdomains.
This constraint applies equally to:
| Surface | Why it triggers the constraint |
|---|---|
Today's web → web link from /plans/[planId] → /enroll | Cross-origin (askflorence.health → app.askflorence.health) |
| Future native iOS / Android app → portal deeplink | The native app and the web portal are separate identity contexts even though they share branding |
| Future partner-channel embed → portal handoff | The partner site is a different origin entirely |
| Future agent-acting-on-behalf-of-member → portal pre-fill | The agent surface and the member portal are different identity contexts |
| Future B2B portal (employer onboarding employees) → member portal | Different origin |
The contract
The portal entry endpoint accepts a self-contained payload. Every piece of context the portal needs to start an application must arrive in the request — not in a cookie, not in sessionStorage, not implied by referrer.
Canonical entry point:
POST https://app.askflorence.health/api/enroll/applications
Body (JSON):
{
// Plan selection (from /plans/[planId])
"planId": "12345NV0010001", // hiosId
"planSnapshot": { // optional but recommended; lets us
"premium": 1051.30, // anchor pricing if the plan changes
"aptc": 1031, // between handoff and submission
"csr": "94", // "" if N/A
"metalLevel": "Silver",
"issuerName": "BCBS"
},
// Calculator context (from the calculator URL params)
"zipCode": "84094",
"countyFips": "49035",
"countyName": "Salt Lake",
"state": "UT",
"income": 21000,
"isMarried": false,
"householdSize": 2,
"people": [ // optional; if absent, server uses size
{ "age": 35, "gender": "male", "isPrimary": true },
{ "age": 30, "gender": "female", "isPrimary": false }
],
// Conversion attribution (optional; per the Creative AdBundance pattern —
// captured client-side on the marketing apex, ride the URL hop intact, fire
// server-side CAPI on submission)
"clickIds": {
"fbclid": "abc123...",
"gclid": "def456...",
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "open-enrollment-2026"
},
// Entry channel (drives future analytics + funnel attribution)
"entryChannel": "web_marketing" // "web_marketing" | "ios_native" |
// "android_native" | "agent_assisted"
// | "partner_embed" | "b2b_portal"
}
Response (200):
{
"applicationId": "uuid-v4",
"resumeUrl": "https://app.askflorence.health/enroll/<applicationId>",
"expiresAt": "2026-08-12T00:00:00Z" // 90-day pre-auth TTL
}The server then either:
- Already has an account for the captured email? — links application to existing account, sends magic-link email if active session not present
- No account yet? — application doc created with
primaryAccountId: null, anonymous resume token cookie set. Account is created when email captured on the consent intro step (or at submission)
Every consumer of this endpoint — web today, iOS tomorrow, partner-channel after — uses the same contract. The server is the single source of truth on application creation semantics.
Web → web (today's implementation)
The marketing-side /plans/[planId] page wires the "Continue to enroll" CTA as a navigation:
ts
const handleContinueToEnroll = () => {
const params = new URLSearchParams({
plan: plan.id, // hiosId
zip: form.zipCode,
county_fips: countyFips,
county_name: countyName,
state,
income: String(income),
married: String(isMarried),
household_size: String(householdSize),
aptc: String(Math.round(aptc)),
csr,
fbclid, // if present in client storage
gclid,
utm_source, utm_medium, utm_campaign,
entry_channel: "web_marketing"
});
window.location.href =
`https://app.askflorence.health/enroll?${params.toString()}`;
};Portal-side /enroll page reads searchParams (Next.js server-component prop) and POSTs to its own /api/enroll/applications route, which creates the doc + sets the pre-auth cookie and renders the consent intro screen.
Why URL params and not POST from the marketing page? Because the click is a top-level navigation, not a fetch. Posting requires either (a) a form submit (which works but introduces a redirect step), or (b) the marketing page calls a fetch to portal first (which fails due to CORS preflight + cookie boundaries). Top-level GET with URL params is the only clean pattern.
Native mobile → portal (future)
When iOS / Android lands, the mobile app uses one of two patterns:
Pattern 1: deeplink to web portal (simplest, ships first). Native app POSTs to /api/enroll/applications over HTTPS, receives resumeUrl, opens it in an in-app webview or system browser. The webview shares no identity context with the native app — the portal is its own auth domain.
Pattern 2: native portal (later, if needed). Native app implements the full enrollment flow with native UI, calling /api/enroll/applications/[id]/section/[name] PATCH endpoints directly. Same API contract; different presentation layer. Auth becomes app-level (member email + device-bound credential).
Both patterns use the same POST /api/enroll/applications to start. Do not invent a "native-only" entry endpoint — that creates two sources of truth and drifts.
Agent-acting-on-behalf-of (future)
Agent surface (separate Linear issue, full agent portal) initiates an enrollment for a member they're helping. The agent UI calls:
POST https://app.askflorence.health/api/enroll/applications
Body adds:
{
...same payload as web...,
"actingAgent": {
"agentId": "...",
"memberEmail": "...", // the member, not the agent
"consentToken": "..." // proof the member signed off on the agent helping
},
"entryChannel": "agent_assisted"
}The application doc is created with primaryAccountId set to the member's account (matched by email or created if new). The agent NEVER becomes the primary account on the application — they're the helper. The application is the member's; the agent is recorded in submittedBy.agentId at submission time.
Partner-channel embed (future hypothetical)
A B2B partner site (e.g. a state-level navigator, an employer benefits page, a low-income clinic referral) wants to drop a user into AskFlorence with their data pre-collected. Same contract — partner site POSTs to /api/enroll/applications with the pre-collected fields + a partnerChannelId + entryChannel: "partner_embed". We return a resumeUrl; partner site does a window-location navigation or a click-through. Same identity-context discipline as native mobile.
A partner-channel auth token mechanism is the only addition needed here — TBD when a real partner channel exists.
What this contract guarantees
- No surface depends on cross-origin storage for the handoff. Cookies, sessionStorage, localStorage — none of those are assumed to traverse origins
- Every entry path uses the same server endpoint so business logic (eligibility re-compute, dedup against existing applications, consent capture, audit-log entry) lives in one place
- Conversion attribution survives the handoff because click IDs ride in the payload, are stored on the application doc, and are forwarded server-side on submission to ad-platform CAPI endpoints
- Future surfaces don't reverse-engineer this — they read this doc, see the contract, implement the same shape
What this contract explicitly does NOT cover
- The auth model for the member once they're inside the portal — that's covered in ENG-187 under the "Auth + identity" plan section
- The Florence integration shape — TBD per the ENG-187 plan
- The agent-portal initiative's full design — separate Linear issue
- The retention model for the application doc — see data-retention-policy.md and the sensitive data handling doc
Versioning
This contract is versioned via the schemaVersion field on the response. v1 = today's shape. Breaking changes (renaming required fields, removing fields, changing types) bump to v2 and require a 90-day deprecation window during which both versions are accepted.