Skip to navigation

Serving a browser or mobile client

The key stays on your server. Your server authorizes your users, because this API cannot.

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.

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.

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

@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, Retries and backoff.

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

@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

RuleWhy
The key exists only in your server processanything on a device is readable
Your BFF authorizes your own usersthis API authenticates you, not them
Derive Idempotency-Key server-sidea client-supplied key can collide with or replay another caller’s job
Rewrite result hrefsupstream downloads need your key; a client cannot fetch them
Attribute every job and result asset idan id you cannot attribute to the caller is someone else’s
Never forward upstream headers verbatimreturn your own; forward only the canonical error body
Bound the upload before reading it allotherwise your BFF is the cheapest thing to exhaust
404, not 403, for someone else’s job403 confirms it exists

Whatever your BFF stores on the way through is subject to the same obligations as the rest — Privacy and retention.