> ## Documentation Index
> Fetch the complete documentation index at: https://docs.purplelabelmd.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect a Lovable storefront

> Wire a Lovable-generated storefront to the platform: which credential goes where, both ways into the flow, and a reusable prompt.

[Lovable](https://lovable.dev) generates a web app from a written prompt. That makes it a fast way
to stand up a branded storefront in front of this platform — and it makes one mistake unusually
easy, because a prompt is pasted whole and the generated code runs in your patient's browser. This
page is written for that: what your storefront owns, what the platform hosts, which credential may
ever reach client code, and a prompt you can reuse.

Fortify is the worked example throughout. Substitute your own brand id and offerings.

<Warning>
  **Read this before you paste anything into a prompt.** On this platform, an API key beginning
  `pk_` is a **server secret**. That is the opposite of the convention some payment tools use, where
  a `pk_` value is the browser-safe one. The browser-safe credential here begins `pub_test_` or
  `pub_live_`.

  A `pk_` value pasted into a Lovable prompt, a client component, or a public repository is a
  disclosed server credential and must be rotated — and rotation is bounded, not instant: the old
  value can still be accepted for up to **30 seconds** afterwards. See
  [your credentials](/pages/integrate/your-credentials).
</Warning>

## What your storefront owns, and what the platform provides

| Your Lovable app owns                      | The platform provides                                                      |
| ------------------------------------------ | -------------------------------------------------------------------------- |
| Brand, copy, imagery, merchandising layout | The price and the terms, resolved server-side from your configured catalog |
| Product browsing and the call-to-action    | The hosted checkout page, including all card collection                    |
| Sending the patient to sign in             | The sign-in page and the patient's session                                 |
| Post-purchase links back into the account  | The clinical questionnaire, the clinician review, and the member portal    |

Your storefront never renders a card field, never holds a payment credential, and never creates a
patient account. It presents a price, and hands the patient to a platform-hosted page with a link.
The full contract for that hand-off is [the checkout door](/pages/integrate/the-checkout-door) —
read it alongside this page; nothing here replaces it.

## The four values, and where each may live

| Credential             | Looks like                      | May it appear in Lovable-generated client code? |
| ---------------------- | ------------------------------- | ----------------------------------------------- |
| API key                | `pk_...`                        | **Never.** Server-side only.                    |
| Publishable brand key  | `pub_test_...` / `pub_live_...` | Yes — that is what it is for.                   |
| Brand id               | `brd_...`                       | Yes. It is an identifier, not a secret.         |
| Webhook signing secret | `psig_...`                      | **Never.** Server-side only.                    |

The publishable key is **brand-scoped and read-only**. It is issued by your onboarding contact, per
brand, and shown once — the same handling as your API key, minus the secrecy. It reaches exactly
two operations and is refused everywhere else, writes included:

* [`GET /v1/account/brands/{brand_id}/storefront`](/api-reference/onboarding/read-one-offerings-price-and-whether-your-brand-can-sell-it) — one offering's price and your brand's readiness to sell it, in one call.
* [`GET /v1/account/brands/{brand_id}/readiness`](/api-reference/onboarding/check-whether-your-brand-is-ready-to-sell) — the readiness report on its own.

Everything else in this guide — starting a questionnaire, reading a payment status, reading an
enrollment status, registering a webhook endpoint — needs the API key and therefore needs a server.

## Permitted browser origins

A browser will not let your page read a cross-origin API response unless the platform grants it.
The grant is a list of **exact origins** on your brand's member domain row, and you manage it
yourself:

```bash theme={null}
# read the current list
curl "$BASE_URL/v1/account/brands/brd_acme/domains/member/browser-origins" \
  --header "Authorization: Bearer $API_KEY" \
  --header "X-Brand-Id: brd_acme"

# replace the list (this is a replace, not a merge)
curl --request PUT "$BASE_URL/v1/account/brands/brd_acme/domains/member/browser-origins" \
  --header "Authorization: Bearer $API_KEY" \
  --header "X-Brand-Id: brd_acme" \
  --header "Content-Type: application/json" \
  --data '{"browser_origins": ["https://my-storefront.example.com", "https://preview.example.com"]}'
```

Three properties that matter to a Lovable project specifically:

* **Exact origins only — a wildcard entry is refused.** Scheme, host and port, nothing else. You
  cannot register a pattern that covers every preview URL Lovable hands you; register the specific
  origins you intend to use. The response returns the entries it refused, by name, so a typo tells
  you which one rather than presenting as "the browser read is broken".
* **It is a replace.** Removing an origin from the array revokes it.
* **Your brand needs a member domain registered first.** Without one there is no row to hold the
  list. See [login URLs and domains](/pages/integrate/login-urls-and-domains).

<Note>
  **Two switches, both yours to ask for.** The browser read needs a publishable key issued for your
  brand **and** your page's origin on the list above. Until both are in place every cross-origin call
  is refused, whatever credential it carries. Build against the server pattern below either way, and
  switch the read to the browser once you have both.

  Both operations declare **either** credential in the reference — your server key or your
  publishable key — so the interactive playground can exercise them with either one. The origin
  allowlist is what the playground cannot reproduce: test the cross-origin path from your own page.
</Note>

## The architecture to build against

Lovable projects commonly pair a generated frontend with a server function (its Supabase
integration, or any backend you already run). Use that server side. It is not optional styling — it
is where your API key has to live.

```
Lovable page  ──►  your server function  ──►  <your API base URL>
 (no secrets)      (holds pk_… API key)        (price, questionnaire, status reads)
      │
      └──────────►  platform hosted checkout  (a link — no API call, no credential)
```

The link to the hosted checkout needs no credential at all, which is why the direct path below
works from a purely client-side app.

## Development setup

| Value                            | Where it comes from                                    | Where it lives                                    |
| -------------------------------- | ------------------------------------------------------ | ------------------------------------------------- |
| API base URL                     | Issued with your key                                   | Server environment variable                       |
| API key (`pk_...`)               | Onboarding contact, shown once                         | Server secret store                               |
| Publishable key (`pub_test_...`) | Onboarding contact, per brand, shown once              | Client environment variable                       |
| Brand id (`brd_...`)             | Your welcome kit                                       | Either side                                       |
| Offering references              | Your welcome kit                                       | Either side                                       |
| Member portal origin             | Your [welcome kit](/pages/integrate/welcome-kit)       | Client — it is where checkout and the portal live |
| Sign-in return URLs              | You choose; must be on your brand's redirect allowlist | Client                                            |

Everything on this site targets the test-mode environment with test-scoped keys and synthetic data
only — see [environments](/pages/environments).

## Path A — checkout first, then the clinical questions

This is the path to build first, and the only one you can ship without a server of your own.

1. **Present the offering.** Read the price on your server with your API key and pass it to the
   page, or read it in the browser with your publishable key once your origins are registered.
   Either way send `X-Brand-Id` — it is required, and with a publishable key it must equal the
   brand that key was minted for, or the read answers `404`.
   Read `price_status` before you show an amount — `pending` means the terms are not fully set up
   and no amount is shown rather than a guessed one — and read the readiness block before you
   render a buy control.
2. **Hand off with a link.** Send the patient to `/checkout` on your brand's member origin, carrying
   the offering:

   ```
   https://<your member origin>/checkout?offering_ref=TADA-20MG-30-10-3B&sku_id=sku_example&therapy=weight_loss
   ```

   The parameters select **what to present, never what anything costs**. A link cannot name a
   price, a discount or a fee.
3. **The patient signs in at the door.** Checkout requires sign-in; the hosted page collects an
   email and runs the platform's own login. Your storefront does not create the account.
4. **The patient pays on the hosted page.** Card details travel from the patient's browser to the
   payment provider. Nothing payment-shaped passes through your code.
5. **The patient continues inside the portal** — to the clinical questionnaire, then to their plan.
   There is no redirect back to your storefront, so your storefront is not where the outcome
   arrives. See below.

<Warning>
  **Which way in is configuration, not a choice your link makes.** Whether your program takes payment
  before the health questions is agreed when your program is set up. On a questions-first deployment
  a link with no enrollment attached shows the patient a plain "health questions come first" stop
  instead of a payment form. Confirm your brand's configured mode with your onboarding contact before
  you build either path.
</Warning>

**What you own on this path, and where it ends.** The storefront, the hand-off link and the reads
your server makes afterwards are yours to build now. Everything from the checkout page onward — the
payment, the questionnaire the patient meets next, the clinician review — is served by the platform
inside the portal, and its content is configuration for your program rather than something your
storefront drives. Confirm what your brand's post-payment questionnaire currently serves before you
write copy or a progress indicator that depends on its shape.

## Path B — qualification questions before checkout

Available, with one constraint that shapes the whole design: **every call on this path is a server
call.** Your page can render questions and collect answers, but it can never talk to these endpoints
itself. Plan the backend before you plan the pages, and confirm your brand's configuration first —
see [before you rely on any of this](#before-you-rely-on-any-of-this).

The questionnaire is served one question at a time, by the server, which owns sequence, branching
and completion. See [how questions work](/pages/integrate/how-questions-work) for the model.

```bash theme={null}
# 1. start a session — the response carries session_id, journey_id and the first node
curl "$BASE_URL/v1/instrument/resolve" \
  --header "Authorization: Bearer $API_KEY" \
  --header "X-Brand-Id: brd_acme"

# 2. submit the current node's answer, KEYED to the node you served
curl --request POST "$BASE_URL/v1/instrument/next" \
  --header "Authorization: Bearer $API_KEY" \
  --header "X-Brand-Id: brd_acme" \
  --header "Content-Type: application/json" \
  --data '{"session_id": "<from step 1>", "node_id": "<the served node.node_id>", "answer": {"codes": ["none"]}}'
```

**Always send `node_id`.** It keys the answer to the node it was collected for. With it, a stale
replay of an already-passed node resyncs cleanly — `200`, current node re-served — and a submit for
a node the session has not reached fails closed with a `renderer.node_mismatch` issue (`422`).
Without it, an answer can misbind to whatever node follows a server-side skip, on a path that
carries a patient's health answers.

Then carry the `journey_id` to checkout:

```
https://<your member origin>/checkout?offering_ref=TADA-20MG-30-10-3B&journey_id=jny_example
```

### Splitting the questionnaire around the payment

`GET /v1/instrument/resolve` takes an optional `phase` parameter that serves one tier of the
questionnaire on its own:

| `phase`         | What it serves                                                                                                |
| --------------- | ------------------------------------------------------------------------------------------------------------- |
| `qualification` | The pre-payment tier. It completes at the qualification end point, and the enrollment then hands to checkout. |
| `clinical`      | The post-payment tier, re-opened by the same enrollment id after checkout.                                    |
| omitted         | The whole questionnaire in one pass, one end point. This is the default and is unchanged.                     |

```bash theme={null}
curl "$BASE_URL/v1/instrument/resolve?phase=qualification&offering=TADA-20MG-30-10-3B" \
  --header "Authorization: Bearer $API_KEY" \
  --header "X-Brand-Id: brd_acme"
```

Serving a phase requires a questionnaire configured in tiers; an unknown value is rejected with
`400`. Whether your brand can serve a phase therefore depends on its questionnaire configuration —
confirm that with your onboarding contact before you design a page around the split.

Four constraints to design around:

* **Server-side only.** These operations need the API key, they carry patient answers, and they are
  not on the browser-safe list — a Lovable page cannot call them directly, with any credential.
  Proxy them through your server function and never return more to the page than the question to
  render.
* **Resume is a header.** Send the enrollment id back as `X-Journey-Id` on a return visit rather
  than starting a second questionnaire.
* **Which questionnaire is served follows your brand's configured therapies.** A brand with more
  than one enabled therapy must name an offering on the entry link so the therapy is unambiguous.
* **Proof-of-identity capture is never part of this questionnaire.** Questions collecting a
  government ID are withheld here and are not counted toward completion; the patient verifies
  identity in the member portal's own identity step. See
  [the identity handoff](/pages/integrate/the-identity-handoff).

## Never infer payment from the browser

There is no redirect back to your storefront after payment, and the outcome never rides the
patient's browser to reach you. A page that concludes "paid" because the patient returned, or
because a timer elapsed, is guessing.

Read the authoritative status from your server instead:

* **`GET /v1/payments/{order_ref}/status`** — the order's own payment state. Fed by confirmed
  events from the payment provider, never by guesswork. Branch on the wire values exactly as the
  reference declares them — `created`, `payment_authorized`, `paid`, `payment_failed`,
  `partially_refunded`, `refunded`, `disputed`, `cancelled`. Prose names like "authorized" or
  "failed" are not what the field carries, and a storefront matching on those never matches.
* **`POST /v1/webhooks/registrations`** — register an endpoint and the response returns your signing
  secret once. Deliveries carry an event type and identifiers only, so a delivery prompts a read
  rather than carrying detail.
* **`GET /v1/journeys/{journey_id}/status`** — the patient-visible enrollment status, for your own
  progress copy. It never carries payment detail, and it may answer with a typed
  [no status yet](/pages/integrate/the-patient-flow#when-a-status-is-not-yet-reportable) instead of
  a status value.

A failed payment still leaves a readable order — the attempt is never invisible.

## States your page must have

A generated app will happily render a price of `undefined`. Ask for these explicitly.

| State                           | What the patient should see                                                                                                                                           |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Price loading                   | A skeleton, not a zero and not a stale amount.                                                                                                                        |
| `price_status` is `pending`     | The offering, without an amount, and no buy control.                                                                                                                  |
| Brand not ready to sell         | The offering, with the buy control disabled and a plain explanation.                                                                                                  |
| Enrollment status unavailable   | That the status cannot be read right now — never an inferred "progressing" or "complete". A missing status is not evidence of movement. Re-read with bounded backoff. |
| `401` from your server function | Your server's credential problem, never the patient's. Show a neutral error; log the outcome, not the credential.                                                     |
| `404` on a brand or offering    | Check the brand id and offering reference. The platform answers `404` for a brand you do not own rather than revealing that it exists.                                |
| Retry                           | Bounded backoff on reads. Never auto-retry a checkout hand-off in a way that could open a second session.                                                             |

## The reusable prompt

Paste this into Lovable and fill the bracketed values. It contains no credentials and instructs the
generated app never to hold one.

```text theme={null}
Build a single-product storefront for a telehealth brand.

ARCHITECTURE
- A public marketing/product page plus one server function. The browser never holds a server secret.
- The server function reads two environment variables: PURPLE_API_BASE and PURPLE_API_KEY.
  PURPLE_API_KEY starts with "pk_" and is a SERVER SECRET. Never reference it in client code,
  never inline it, never log it, never expose it through a public route.
- The client may use only: PURPLE_BRAND_ID (starts with "brd_") and, if provided,
  PURPLE_PUBLISHABLE_KEY (starts with "pub_test_" or "pub_live_"). Both are safe in the browser.

SERVER FUNCTION: GET /api/offering
- Calls: GET {PURPLE_API_BASE}/v1/account/brands/{PURPLE_BRAND_ID}/storefront?offering_ref={OFFERING_REF}
- Headers: Authorization: Bearer {PURPLE_API_KEY}, X-Brand-Id: {PURPLE_BRAND_ID}
- Returns to the page only: price_status, the display amount and currency when present, and the
  readiness flags. Never forward the raw upstream response and never forward headers.

PRODUCT PAGE
- Fetches /api/offering on load. Three render states: loading (skeleton), price_status "pending"
  (show the offering with no amount and no buy control), and ready (show the amount).
- If readiness says the brand cannot sell, render the buy control disabled with a short
  explanation. Do not hide the product.
- The buy control is a LINK, not a fetch:
  https://{MEMBER_ORIGIN}/checkout?offering_ref={OFFERING_REF}&sku_id={SKU_ID}&therapy={THERAPY}
  Open it in the same tab. Do not collect any payment detail. Do not render a card field.

AFTER CHECKOUT
- There is no redirect back to this site and no payment result in the browser. Do not add a
  "thank you" page that claims a payment succeeded, and do not poll any browser value for it.
- Add a "Manage your plan" link to https://{MEMBER_ORIGIN} for returning patients.

SIGN IN
- A "Sign in" link goes to
  {PURPLE_API_BASE}/v1/auth/login?brand={PURPLE_BRAND_ID}&returnTo={THIS_SITE_URL}
- Do not build a password form. The platform hosts sign-in.

RULES
- No secret in client code, in any prompt, or in the repository. No card field. No patient health
  information stored in this app. No analytics event carrying an identifier from the API.
- Every network failure gets a visible, plain-language state. Never render "undefined" or "NaN".

FILL IN: OFFERING_REF, SKU_ID, THERAPY, MEMBER_ORIGIN, THIS_SITE_URL.
```

## Development walkthrough checklist

1. Confirm with your onboarding contact which entry path your brand is configured for.
2. Store the API key in your server environment. Confirm it appears in no client bundle.
3. Read the offering on your server and confirm `price_status` and the readiness block render.
4. Build the checkout link and confirm it opens the hosted page wearing your brand.
5. Complete a test-mode purchase and confirm your server — not the browser — observes the paid
   state through the payment status read or a webhook delivery.
6. Confirm the patient lands in the member portal afterwards.
7. Only then, if you want the browser read: register your origins, ask for your publishable key,
   and move the price read client-side.

## Common integration errors

| Symptom                                                      | Cause                                                                                                                                                                             |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The browser blocks the price read                            | Your origin is not registered, or no origin is registered at all. Register exact origins on the member domain row.                                                                |
| `401` on every call                                          | The key is missing or revoked. The body is deliberately opaque — it never says which, and never reveals whether a key exists.                                                     |
| `400` with type `https://purple.md/problems/malformed-brand` | The key was accepted but `X-Brand-Id` is missing or is not a well-formed `brd_` id. This is a different wall from the `401`, and it is checked before your request is dispatched. |
| `404` for a brand you believe exists                         | The brand is not one of yours. Existence is never revealed across accounts.                                                                                                       |
| The checkout link shows "health questions come first"        | Your brand is configured questions-first; attach an enrollment before sending the patient to checkout.                                                                            |
| The price shows as `pending` forever                         | The offering's terms are not fully configured. See [pricing](/pages/integrate/pricing).                                                                                           |
| A second charge after a retry                                | Do not retry the hand-off blindly. The hosted page creates its session under an idempotency key; your storefront should not create a second entry.                                |

## Before you rely on any of this

Four things to confirm for **your** brand before you plan a launch around this page. None of them
are code you write — they are configuration and credentials, and your onboarding contact owns them.

1. **Which entry path your program is configured for.** A questions-first program shows a plain
   "health questions come first" stop instead of a payment form when a link carries no enrollment.
2. **Whether your publishable key is issued and your browser origins are registered.** Until both
   are in place, the two browser reads are unavailable to your page and the server pattern above is
   your only route to a price.
3. **Whether your brand's questionnaire is configured in tiers**, if you want the split entry with
   `phase`. Without tiers there is no qualification tier to serve on its own.
4. **What your brand's post-payment questionnaire currently serves.** The platform owns that
   content; confirm its current shape before you design copy or a progress indicator around it.

And two standing facts about this site's environment: every example runs in **test mode** — no live
rails, no real card, no real order — and the prompt below is a **starting point to verify in your
own project**, not a certified integration. Run your own flow before you ship one.
