> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.morphiaone.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.morphiaone.ai/_mcp/server.

# Serving a browser or mobile client

Anything shipped to a device is readable. An API key in a browser bundle, a mobile
binary, a mobile app's config file or a client-side environment variable is a
published key. So a front end never calls this API directly — it calls a small
backend of yours that holds the key.

```text
browser / app  -->  your BFF  -->  MorphiaOne Vision API
   (no key)       (holds key)         (X-API-Key)
```

Three routes: submit, poll, fetch a result image. Proxy nothing else.

## The rule that is easy to miss

**This API authenticates your account, not your end users.** Within your account it
provides no isolation between your users, by design — one key, one environment. Two of
your customers sharing that key share every asset and every job.

So the authorization your users need is one only you can implement: your BFF has to
record which of *your* callers submitted which job, and which result assets that job
produced, and refuse any id it cannot attribute to the caller asking. A BFF that
forwards whatever job or asset id it is given lets one of your customers read another
customer's face analysis.

That check is the difference between a proxy and a BFF.

## Submit

The submit route does two upstream calls: it uploads the image as an asset, then
submits the job that names it. The asset id never needs to reach your client.

```python
import os, uuid
import httpx
from fastapi import FastAPI, HTTPException, Response, UploadFile

API = os.environ["VISION_BASE_URL"]
KEY = os.environ["VISION_API_KEY"]          # server-side only, from your secret store
MAX_BYTES = 12 * 1024 * 1024

app = FastAPI()


@app.post("/api/skin")
async def submit(image: UploadFile) -> dict[str, str]:
    payload = await image.read(MAX_BYTES + 1)
    if len(payload) > MAX_BYTES:
        # Bound it before you have read it all, or your BFF is the easiest thing
        # in the chain to exhaust.
        raise HTTPException(status_code=413, detail="image too large")

    # Derived by you, once per incoming request, and reused if you retry the
    # submit below. A client-supplied key lets one caller collide with, or
    # replay, another caller's job.
    idempotency_key = uuid.uuid4().hex

    async with httpx.AsyncClient(timeout=30.0) as client:
        asset = await client.post(
            f"{API}/v1/assets",
            headers={"X-API-Key": KEY, "Content-Type": image.content_type},
            content=payload,
        )
        if asset.status_code >= 400:
            # Forward the canonical error body. Never the key, never upstream headers.
            raise HTTPException(status_code=asset.status_code, detail=asset.json())

        upstream = await client.post(
            f"{API}/v1/skin/analyses",
            headers={"X-API-Key": KEY, "Idempotency-Key": idempotency_key},
            json={
                "inputs": [{"role": "source", "asset_id": asset.json()["id"]}],
                "concerns": ["acne", "redness"],
            },
        )
    if upstream.status_code >= 400:
        raise HTTPException(status_code=upstream.status_code, detail=upstream.json())

    job = upstream.json()
    remember_job_for_caller(job["id"])       # yours to write; see below
    return {"job_id": job["id"]}
```

`remember_job_for_caller`, `remember_asset_for_caller` and the two `is_..._issued_to_caller`
checks are yours because only you know what a caller is — a signed-in account, a
session, a device token. Whatever it is, persist the association somewhere every route
below can read it, and somewhere that survives a deploy and is shared across workers.
An in-process dictionary works until the second replica.

## Poll

```python
@app.get("/api/jobs/{job_id}")
async def poll(job_id: str) -> dict:
    if not is_job_issued_to_caller(job_id):
        raise HTTPException(status_code=404, detail="unknown job")   # 404, not 403

    async with httpx.AsyncClient(timeout=15.0) as client:
        upstream = await client.get(f"{API}/v1/jobs/{job_id}", headers={"X-API-Key": KEY})

    job = upstream.json()
    # Attribute every result asset to the caller who owns the job, then rewrite
    # each `href` onto your own route. An upstream href needs the key your client
    # does not have, so passing one through hands the client a 401 it can never fix.
    for asset_id in result_asset_ids(job.get("output")):
        remember_asset_for_caller(asset_id)
    return rewrite_result_hrefs(job)          # -> /api/assets/{asset_id}
```

Pass the upstream `Retry-After` on to your client rather than polling on your own
schedule, and pass `ETag` / `If-None-Match` through if you cache —
[Asynchronous jobs](/concepts/jobs), [Retries and backoff](/concepts/retries).

Answer `404` and not `403` for a job that is not the caller's, for the same reason
we do: `403` confirms the job exists.

## Fetch a result image

```python
@app.get("/api/assets/{asset_id}")
async def result(asset_id: str):
    if not is_asset_issued_to_caller(asset_id):
        raise HTTPException(status_code=404, detail="unknown asset")

    async with httpx.AsyncClient(timeout=30.0) as client:
        upstream = await client.get(
            f"{API}/v1/assets/{asset_id}/content", headers={"X-API-Key": KEY}
        )
    if upstream.status_code >= 400:
        raise HTTPException(status_code=upstream.status_code, detail=upstream.json())
    return Response(
        content=upstream.content,
        media_type=upstream.headers.get("content-type", "application/octet-stream"),
    )
```

The asset id is a single path segment you have already attributed, so there is no
path to validate — only the attribution to check.

## The rules, in one table

| Rule                                       | Why                                                                   |
| ------------------------------------------ | --------------------------------------------------------------------- |
| The key exists only in your server process | anything on a device is readable                                      |
| Your BFF authorizes your own users         | this API authenticates you, not them                                  |
| Derive `Idempotency-Key` server-side       | a client-supplied key can collide with or replay another caller's job |
| Rewrite result `href`s                     | upstream downloads need your key; a client cannot fetch them          |
| Attribute every job and result asset id    | an id you cannot attribute to the caller is someone else's            |
| Never forward upstream headers verbatim    | return your own; forward only the canonical error body                |
| Bound the upload before reading it all     | otherwise your BFF is the cheapest thing to exhaust                   |
| `404`, not `403`, for someone else's job   | `403` confirms it exists                                              |

Whatever your BFF stores on the way through is subject to the same obligations as
the rest — [Privacy and retention](/concepts/privacy-retention).