# Developer guide for elvix

> The REST surface explained: verify tokens (the end-user session token the SDK hands you, or an Application API key), the two verify paths, the sign-in gate, and the endpoints teams reach for. The full interactive reference is at /docs/api.

## Mint an API key in 30 seconds

Every server-side call (`verifyElvixToken`, the management API, the MCP server, webhook administration) authenticates with an elvix API key shaped `eak_<random>`. A key has a SCOPE and an ACCESS level. Scope is either ONE application (mint it on that app's API keys tab) or the WHOLE workspace (mint it at `/console/api-keys`, optionally restricted to a subset of apps) for an agent or backend that spans several apps. Access is Read or Full (read + write). Step by step:

1. Open `https://elvix.is/console` and sign in.
2. For a WORKSPACE key (manages many apps): open **API keys** at `/console/api-keys` and click **New API key**. For a SINGLE app: open that app's **API keys** tab at `/console/applications/<APP_ID>/api-keys` instead.
3. Name it (e.g. `prod-verify`, `ops-writer`), pick the access level (**Read** or **Full**), and for a workspace key pick the scope (**All applications** or a specific subset).
4. The raw `eak_...` token is shown ONCE on the next screen. Copy it now; elvix stores only its SHA-256 hash.
5. Ship the token to your server's secret store. Reference it as `Authorization: Bearer eak_...` on REST calls or as `ELVIX_API_KEY` in env (used by `@elvix.is/sdk/mcp` and any custom client).

> **Note:** Access is a property of the KEY, not a role. A **Read** key covers the read endpoints (`POST /api/v1/verify`, listing webhooks, etc.). A **Full** (read + write) key unlocks every mutation endpoint (create / update / delete webhooks, mutate roles + scopes, ban members, mint additional keys). It is independent of the `user` / `developer` end-user roles. Least-privilege: keep a Read key in your production app, mint a separate Full key for ops scripts and rotate it freely.

> **Warning:** The `clientId` (shown on the **Credentials** card on the Application overview) is PUBLIC, safe to ship in browser bundles, used by `<ElvixProvider>` and the public bootstrap. API keys (`eak_...`) are SERVER-ONLY, never expose them to the browser. The two are not interchangeable.

## Rotate an API key

Two flavours, both requiring a Write key: `POST /api/applications/{id}/api-keys/{keyId}/rotate-with-grace` provisions a successor with the same envelope and keeps the predecessor valid for 24 hours so deploys can roll without an outage; `POST /api/applications/{id}/api-keys/{keyId}/rotate` swaps the token in place immediately (any traffic still carrying the predecessor 401s after the call returns). Prefer the grace flow in production.

## Verify a token on your server

`verifyElvixToken` from `@elvix.is/sdk/server` exchanges the bearer token your sign-in surface gave you for the live user envelope. It POSTs to `https://elvix.is/api/v1/session` (override via baseUrl), re-checks ban / pause / delete state on every call, and returns a discriminated union you narrow with `result.ok`.

```ts
declare function verifyElvixToken(args: VerifyArgs): Promise<ElvixVerifyResult>;
declare function verifyElvixToken(token: string, opts?: VerifyOptions): Promise<ElvixVerifyResult>;

// VerifyArgs (object form, canonical since 0.6.5):
type VerifyArgs = {
  /** End-user session token. The value the SDK handed you via `onResult({ token })`. */
  token: string;
  /** Your Application's client ID. Optional, but recommended — lets elvix scope
   *  the verify against the right application when one user spans multiple. */
  clientId?: string;
  /** Override the elvix origin for testing / proxy setups. */
  baseUrl?: string;
  /** Per-request timeout in ms. Default 5000. */
  timeoutMs?: number;
};

// VerifyOptions (legacy positional form):
type VerifyOptions = {
  /** Override the elvix origin for testing / proxy setups. */
  baseUrl?: string;
  /** Per-request timeout in ms. Default 5000. */
  timeoutMs?: number;
};

type ElvixVerifyOk = {
  ok: true;
  user: ElvixUser;
  roles: string[];
  scopes: string[];
  memberships: string[]; // plain string array on ElvixVerifyOk, there is no exported ElvixMembership object type in dist/. The wire envelope returns memberships as string[].
};
type ElvixVerifyErr = {
  ok: false;
  error: "invalid_token" | "expired" | "revoked" | "membership_blocked" | "rate_limited";
  message?: string;
};
type ElvixVerifyResult = ElvixVerifyOk | ElvixVerifyErr;

type ElvixUser = {
  id: string;
  email: string;
  name?: string;
  avatarUrl?: string;
};
```

## Next.js App Router (canonical pattern)

<!-- app/app/page.tsx -->
```tsx
// app/app/page.tsx
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { verifyElvixToken } from "@elvix.is/sdk/server"

export const dynamic = "force-dynamic"

export default async function AppPage() {
  const token = (await cookies()).get("elvix_token")?.value
  if (!token) redirect("/sign-in")

  const result = await verifyElvixToken({
    token,
    clientId: process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!,
    // add baseUrl: process.env.NEXT_PUBLIC_ELVIX_BASE_URL when self-hosting
  })
  if (!result.ok) redirect("/sign-in")

  return <h1>Hello, {result.user.name ?? result.user.email}</h1>
}
```

The token only travels server-side. Never expose it to the client.

## Verifying a user: two paths

- `POST /api/v1/session`: **the SDK path.** Send the end-user session token (the value `<ElvixSignIn onResult={({token})...}>` gave you, or what `@elvix.is/sdk/server`'s `verifyElvixToken` wraps) as a Bearer. The token is self-authenticating: no API key needed. Best for cross-origin apps where each request already carries the user's token.
- `POST /api/v1/verify`: **the API-key path.** Your backend holds an Application API key and receives a user token out-of-band; send the API key as the Bearer and the user token in the JSON body. Best for pure server-to-server backends.

> **Note:** Both re-check the live session AND the user/membership status on every call, so a banned, paused, or signed-out user comes back as `ok:false` (or `success:false`) within one request. Call on each protected request, or cache for a few seconds. Either way bans are enforced server-side.

## POST /api/v1/session: verify an end-user token

Bearer auth with the end-user's own session token. Returns a flat envelope with the live role / scope / membership slugs for the token's application. `ok:false` (HTTP 401/403/429) when the session is gone, the membership is blocked, or you're rate-limited.

```bash
curl -X POST https://elvix.is/api/v1/session \
  -H "Authorization: Bearer <end-user session token>"
```

<!-- Successful response -->
```json
{
  "ok": true,
  "userId": "u_...",
  "email": "alice@example.com",
  "name": "Alice",
  "applicationId": "app_...",
  "status": "active",
  "roles": ["user", "editor"],
  "scopes": ["read:profile", "write:profile"],
  "memberships": ["pro"],
  "expiresAt": "2026-06-28T00:00:00.000Z"
}
```

## POST /api/v1/verify: verify with an Application API key

Bearer auth with an Application API key (minted in Console → your Application → Credentials); the user's token rides in the JSON body. Wraps the same data in the Spine `ResponseDto<T>` envelope: `success === true` carries `data`, failures carry a stable enum-shaped `errorMessage`. Management mutations additionally require the key to be a Write (read + write) key.

```bash
curl -X POST https://elvix.is/api/v1/verify \
  -H "Authorization: Bearer eak_..." \
  -H "Content-Type: application/json" \
  -d '{ "token": "<end-user session token>" }'
```

## Other endpoints teams reach for

- `POST /api/v1/auth/otp/send`: send a one-time code to an email.
- `GET /api/me/roles?applicationId=` · `/api/me/scopes` · `/api/me/memberships`: the live slugs for the signed-in user. The SDK's `useUserRoles` / `useUserScopes` / `useUserMemberships` hooks poll these.
- `POST /api/applications/{id}/webhooks`: subscribe to lifecycle events (`user.signed_in`, `user.left`, etc.).

> **Note:** Rate limits per API key default to 60 requests/minute and 10000 requests/day. Customise per-key in Console.

## CLI / device login (RFC 8628 device-authorization grant)

Headless tools (the `plm` CLI, `elvix login`, any device with no browser) authenticate with the OAuth 2.0 Device Authorization Grant. The tool shows the user a short code, the user approves it in their browser, and the tool polls until it receives an `eak_` Personal Access Token bound to the approving user. These two endpoints are unauthenticated (no `eak_` key needed) and return flat OAuth-shaped JSON, not the Spine envelope.

1. **Request a code.** `POST /api/v1/device/code` with `{ "client_id": "<your app clientId>" }` returns `{ device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }`. `verification_uri` is `https://elvix.is/device`; `verification_uri_complete` is the same URL with the code prefilled (`/device?code=<user_code>`).
2. **Show the user the code.** Print `user_code` and tell them to open `verification_uri` (or open `verification_uri_complete` for them). The `/device` approval card renders `<ElvixSignInForm>` with the app's Console-configured sign-in methods and branding, so the user signs in (or is already signed in) and approves with one tap.
3. **Poll for the token.** `POST /api/v1/device/token` with `{ "device_code", "client_id" }` every `interval` seconds. While the user has not finished, it returns HTTP 400 with `{ "error": "authorization_pending" }` (back off on `slow_down`). On approval it returns `{ access_token, token_type, scope }` where `access_token` is an `eak_` Personal Access Token with `source=device`. Terminal errors: `expired_token` (the code's `expires_in` elapsed or it was already claimed), `access_denied` (the user declined).

```bash
# 1. Request a code
curl -X POST https://elvix.is/api/v1/device/code \
  -H "Content-Type: application/json" \
  -d '{ "client_id": "<your app clientId>" }'
# -> { "device_code": "...", "user_code": "WDJB-MJHT",
#      "verification_uri": "https://elvix.is/device",
#      "verification_uri_complete": "https://elvix.is/device?code=WDJB-MJHT",
#      "expires_in": 900, "interval": 5 }

# 2. (user opens verification_uri, enters user_code, approves)

# 3. Poll every `interval` seconds until you get an access_token
curl -X POST https://elvix.is/api/v1/device/token \
  -H "Content-Type: application/json" \
  -d '{ "device_code": "...", "client_id": "<your app clientId>" }'
# pending -> 400 { "error": "authorization_pending" }
# approved -> { "access_token": "eak_...", "token_type": "Bearer", "scope": null }
```

> **Note:** The `@elvix.is/sdk` ships this end to end: the `elvix login` CLI runs the whole flow, and the server helpers `requestDeviceCode()` / `pollDeviceToken()` (from `@elvix.is/sdk/server`) wrap the two endpoints with the correct polling and back-off if you build your own CLI. The minted `eak_` carries `source=device` and is revocable by the user from their Account > Connected devices (`/account/devices`).

## What `verifyElvixToken` returns

The host-facing user envelope is the canonical place to read identity, locale, region, and avatar. One call covers the fields most hosts need; reach for the per-category endpoints below only when you want to write or you need something more specific (multiple addresses, legal entities, languages).

<!-- verifyElvixToken response -->
```ts
// Successful response (POST /api/v1/session)
{
  ok: true,
  userId: "cmp...",
  email: "user@example.com",

  // Identity (sourced from UserProfile; falls back to legacy User.name)
  name: "Edvard Grei",          // = fullName; kept for backwards compat
  fullName: "Edvard Grei",
  givenName: "Edvard",
  familyName: "Grei",

  // Avatar (UserProfile.avatarUrl, falls back to User.avatarUrl from OAuth)
  avatarUrl: "https://cdn.021.is/elvix/<app>/users/<uid>/avatar-256.webp?v=...",

  // Locale / timezone (UserProfile, falls back to UserRegion)
  locale: "en-US",
  timezone: "Europe/Berlin",

  // Region (UserRegion; null when the user has not set it)
  region: {
    country: "DE",
    uiLocale: "de-DE",
    timeZone: "Europe/Berlin",
    currency: "EUR",
    measurementSystem: "metric",
  },

  // App context
  applicationId: "cmp...",
  status: "active",
  roles: ["user"],
  scopes: [...],
  memberships: [...],

  // Avatar / banner variant inventory (the SDK uses these to render
  // the right CDN URL with cache-busting). 128/256/1200 for avatar,
  // 768/1500/2400 for banner; undefined / null when not uploaded.
  avatarSizes: [128, 256, 1200],
  avatarUpdatedAt: "2026-06-02T18:00:00.000Z",
  bannerSizes: [768, 1500, 2400],
  bannerUpdatedAt: "2026-06-02T18:00:00.000Z",

  expiresAt: "2026-07-02T18:00:00.000Z",
}
```

> **Note:** `name` and `fullName` are the same string. `name` is kept so existing customer code that reads `result.user.name` from older SDK versions does not break. Pick one and stick with it in new code.

## Profile categories: what the host can read

Seven categories. The verify envelope above covers the basics (identity, avatar/banner inventory, locale, region). Everything else lives behind a per-category endpoint. Today the per-category writers are SDK-only, they accept the user's cookie / app bearer, not an Application API key, so hosts compose them via the SDK on the customer's frontend, not from their own backend.

| Category | In verify envelope | Mutation surface | Webhook on change |
|---|---|---|---|
| Identity (given / family / full name, DOB, pronouns) | yes (name fields) | `PATCH /api/account/profile/identity` (sdk-only) | `user.profile_updated` |
| Avatar (CDN variants + URL) | yes (avatarUrl + avatarSizes + avatarUpdatedAt) | `PUT/DELETE /api/account/self/images/avatar` (sdk-only) | `user.avatar_changed` |
| Banner (CDN variants) | yes (bannerSizes + bannerUpdatedAt) | `PUT/DELETE /api/account/self/images/banner` (sdk-only) | `user.banner_changed` |
| Billing addresses | no (use `<ElvixAddressBook kind="billing">`) | `GET/POST/PATCH/DELETE /api/account/profile/addresses` (sdk-only) | none today |
| Shipping addresses | no (use `<ElvixAddressBook kind="shipping">`) | same route, `kind="shipping"` | none today |
| Tax & business (legal entities, VAT) | no (use `<ElvixLegalEntities>`) | `GET/POST/PATCH/DELETE /api/account/profile/entities` (sdk-only) | none today |
| Languages (fluencies + level) | no (use `<ElvixLanguages>`) | `GET/POST/PATCH/DELETE /api/account/profile/languages` (sdk-only) | none today |
| Region (country, currency, time format) | yes (region object) | `GET/PUT/PATCH/DELETE /api/account/profile/region` (sdk-only) | none today |

## Avatar / banner CDN URLs

The variant URLs are public + cacheable. Pattern: `https://cdn.021.is/elvix/<APP_SLUG>/users/<USER_ID>/avatar-<SIZE>.webp?v=<UNIX_MS>`. The query string is the `avatarUpdatedAt` epoch so each new upload invalidates the browser cache. Avatar sizes today: `128`, `256`, `1200`. Banner sizes: `768`, `1500`, `2400`. Host backends construct URLs directly from `avatarSizes` + `avatarUpdatedAt` returned by verify; no extra round-trip needed.

Frontend hosts should prefer the rendering components `<ElvixUserAvatar userId={...} size={256} />` / `<ElvixUserBanner userId={...} size={1500} />` from `@elvix.is/sdk/react`, they handle the CDN URL + the OAuth fallback + the initials placeholder in one line.

## Address book on the host (frontend)

Drop the SDK component on a checkout / settings page; it reads + writes addresses against `/api/account/profile/addresses` automatically. Billing and shipping share one Prisma table and one route, the `kind` prop is the discriminator.

<!-- components/checkout-address.tsx -->
```tsx
import { ElvixProvider, ElvixAddressBook } from "@elvix.is/sdk/react";

export function CheckoutAddressStep() {
  return (
    <ElvixProvider clientId={process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!}>
      <ElvixAddressBook
        kind="shipping"
        onChange={(addresses) => {
          // Fires on every CRUD success. `addresses[0]` is the default;
          // pass it to your order intent.
        }}
      />
    </ElvixProvider>
  );
}
```

## Identity webhook (`user.profile_updated`)

When a user changes their identity (any of given/family/full name, DOB, gender, pronouns) the dispatcher fires `user.profile_updated` to every Application the user is a member of. Use this on your backend to refresh the cached display name without re-running `verifyElvixToken` on every request.

```ts
// app/api/webhooks/elvix/route.ts (excerpt)
case "user.profile_updated":
  await db.userProfile.update({
    where: { elvixUserId: event.data.userId },
    data: { name: event.data.name },
  });
  break;
```

## Reading rich profile data from the host's backend (today's options)

Today there is NO management-API-key endpoint to read another user's address, legal entity, or language list, those routes accept only the user's own session token. Three patterns work today:

- **Best**: surface the data via the SDK component on the customer frontend, then POST what you need to your own backend. `<ElvixAddressBook>`, `<ElvixLegalEntities>`, `<ElvixLanguages>` all expose `onChange` so you can mirror the data on submit.
- **Cache via webhook**: subscribe to `user.profile_updated` for the identity columns, and (coming soon) `address.*` / `language.*` / `legal_entity.*` events for the per-category writers.
- **Custom mirror endpoint on the host**: have your frontend POST the data to your own backend immediately after the SDK saves it. The user is already authenticated on your origin; you skip the elvix round-trip.

> **Warning:** Management-API-key access to addresses, entities, languages, region is intentionally not implemented today, the privacy frame is that those fields are the user's, not the workspace owner's, to retrieve. If you have a strict back-office use case (invoicing, compliance audit) reach out; we will scope it per-Application instead of unlocking the whole table.

## Cross-origin Google sign-in flow

All Google OAuth runs through `elvix.is` regardless of which customer origin started it. Your customer domain is NEVER added to Google's authorized JS origins, so adding a new app is a `clientId` swap with zero Google Cloud Console changes.

Wire diagram of a single sign-in:

- Browser sits on `https://your.app/sign-in`. SDK renders `<ElvixSignIn>` with your `clientId`.
- User clicks Continue with Google. SDK navigates the top frame to `https://elvix.is/api/auth/google/start?clientId=<id>&returnTo=https://your.app/sign-in`. (No popup, no GIS on your origin.)
- elvix.is calls Google with `redirect_uri=https://elvix.is/api/auth/google/callback` and `scope=openid email profile` (three non-sensitive scopes; never People-API).
- Google bounces back to `https://elvix.is/api/auth/google/callback?code=...`. elvix exchanges the code, mints an end-user session, and 302s the browser to `<returnTo>#elvix_token=<token>`.
- Back on `https://your.app/sign-in`, `<ElvixProvider>` reads the fragment, strips it from the URL via `history.replaceState`, and dispatches `elvix:return-token`. `<ElvixSignIn>` / `<ElvixSignInForm>` fire your `onResult` and `onAuthenticated` callbacks with the same `{ ok: true, token, redirect }` shape an in-frame success carries.

> **Note:** The fragment never reaches your server, so the token never appears in access logs, referrer headers, or analytics tools. Once `<ElvixProvider>` consumes it, the URL is clean and the token lives in the SDK's in-memory store (mirrored to the `elvix_token` cookie by your `onResult` so server components can verify).

> **Warning:** Requesting any Google sensitive scope (People API: birthdays, addresses, phone numbers, etc.) flips the OAuth client into the verification gate. Until the app is verified, Google blocks consent for users not on the test-users list and the error surfaces as `origin_mismatch` on the consent screen, NOT as a scope-permission error. Stay on `openid email profile` unless you've actually shipped Google verification for the app.

## Sign-in gate (who can sign into your app)

Every Application has a `signinGate` setting that controls who is allowed to sign in. Switching it is one management call (a Write key); the new state takes effect immediately for every method (OTP, Google, passkey).

- `public` (default): anyone with a valid identity can sign in. New users create an end_user row + ApplicationUser membership on first sign-in.
- `private_beta`: only emails on the BetaInterest allowlist with `status="approved"` can sign in. Everyone else lands on a waitlist surface. Workspace owners + members always bypass.
- `closed`: only emails that ALREADY have an ApplicationUser membership can sign in. New sign-ups are refused. Use this when you want to lock the door after migration / launch / incident.

> **Note:** The gate is enforced server-side on every sign-in finisher (OTP verify, Google credential / callback, passkey finish) BEFORE the user row is created and BEFORE the session is minted. There is no client-side way to bypass it: a hostile SDK or hand-crafted request hits the same gate as the SDK's own components.

## POST /api/applications/{id}/signin-gate

Switch the gate. A Write key is required. Body: `{ "gate": "public" | "private_beta" | "closed" }`. Returns the updated state. Flipping to `public` auto-resolves every pending BetaInterest to `notify_when_public` so the people who asked for early access still get the launch email.

```bash
curl -X POST https://elvix.is/api/applications/<APP_ID>/signin-gate \
  -H "Authorization: Bearer eak_..." \
  -H "Content-Type: application/json" \
  -d '{ "gate": "private_beta" }'
```

## Granting private access (the limited-access page)

When the gate is `private_beta` or `closed`, only emails on the BetaInterest allowlist (or existing members) can sign in. Manage the allowlist from Console at

`https://elvix.is/console/applications/<APP_ID>/limited-access`, list, approve, decline, remove, and add emails one-at-a-time or in bulk.

## Allowlist API

Programmatic access for migrations, CI seeding, or back-office tooling. All require a Write key.

- `GET /api/applications/{id}/beta-interests`: list every BetaInterest (paginated). Filter by `status` query param.
- `POST /api/applications/{id}/beta-interests`: add one or many emails. Body `{ "emails": ["a@x.com"], "status": "approved" }` admits them straight; omit `status` for `pending`.
- `POST /api/applications/{id}/beta-interests/{interestId}/approve`: approve a pending row.
- `POST /api/applications/{id}/beta-interests/{interestId}/decline`: decline a pending row (keeps the row for audit; status flips to `declined`).
- `DELETE /api/applications/{id}/beta-interests/{interestId}`: remove the row entirely.

> **Note:** Two extra guards run alongside the gate: a soft-deleted user's email can NEVER sign back in (no silent resurrection, they get `user_deleted`), and an archived email alias can NEVER mint a new account (the user signalled they're done with that address). These checks fire on every method, every surface.

## Webhooks

Subscribe a HTTPS endpoint in Console (Applications → your app → Webhooks). The dispatcher signs every delivery, retries with exponential backoff for 15 attempts over ~3 days, and quarantines a permanently broken endpoint so it stops burning attempts. Consumers dedup by the `Elvix-Event-Id` header.

## Signature scheme

`Elvix-Signature: t=<unix_ts>,v1=<hex>` where `hex = HMAC-SHA256(\`${t}.${rawBody}\`, secret)`. Receiver builds the same string, HMACs with the shared secret, constant-time compares. Replay-protected by checking `t` is within 5 minutes of `now`.

<!-- app/api/webhooks/elvix/route.ts -->
```ts
import { verifyElvixWebhook } from "@elvix.is/sdk/server";

export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get("elvix-signature") ?? "";
  const eventId = req.headers.get("elvix-event-id") ?? "";

  const r = verifyElvixWebhook({
    rawBody,
    signature,
    secret: process.env.ELVIX_WEBHOOK_SECRET!,
  });
  if (!r.ok) {
    console.warn("webhook verify failed:", r.error);
    return new Response("invalid signature", { status: 401 });
  }

  // Idempotent: dedup on eventId. The dispatcher may replay on transient 5xx.
  switch (r.event.type) {
    case "user.signed_in":
      console.log("sign-in:", r.event.data.userId, "via", r.event.data.method);
      break;
    case "user.banned":
    case "user.deleted":
      // Revoke your local state for this user.
      break;
    case "user.role_added":
    case "user.role_removed":
      // Sync r.event.data.roles to your DB.
      break;
  }

  return new Response("ok");
}
```

## Event taxonomy

Every event is discriminated by `type` and carries a typed `data` payload. Twenty-three event types across five domains:

| Domain | Event types |
| --- | --- |
| Session | `user.signed_in` `user.signed_out` `user.session_revoked` `user.session_expired` |
| Admin lifecycle | `user.banned` `user.unbanned` `user.paused` `user.resumed` `user.deleted` `user.restored` |
| Self lifecycle | `user.inactivated` `user.reactivated` `user.left` |
| Role / scope / membership | `user.role_added` `user.role_removed` `user.scope_added` `user.scope_removed` `user.membership_changed` |
| Profile | `user.profile_updated` `user.username_changed` `user.avatar_changed` `user.banner_changed` `user.identity_changed` |

> **Note:** Event types are additive across SDK versions. Consumers should ignore unknown `type` values and not throw, a future event added on the server side never breaks an older receiver.

## SDK i18n

Every `<Elvix*>` React component is rendered through `useT()` from `@021.is/spine-i18n/react`. Wrap your app in `<ElvixProvider locale="de">` and the entire SDK surface flips to German on the spot. Fifteen locales ship today: en, de, es, fr, it, nl, pt-BR, pt-PT, tr, pl, cs, sv, el, ru, uk.

<!-- app/layout.tsx -->
```tsx
import { ElvixProvider, ElvixSignIn } from "@elvix.is/sdk/react";

export default function RootLayout({ children, locale }: { children: React.ReactNode; locale: string }) {
  return (
    <ElvixProvider clientId="your-app" locale={locale}>
      <ElvixSignIn />
      {children}
    </ElvixProvider>
  );
}
```

English (`en`) is bundled inside the SDK so the first paint is always instant. The other fourteen catalogs are fetched on demand from `https://elvix.is/api/v1/i18n/elvix/<locale>.json` and cached in-memory for the page lifetime. Override the source with `i18nBase` on the provider if you self-host translations.

> **Note:** Override individual strings per embed via the existing `copy` prop on `<ElvixSignIn>`, that still wins over the locale catalog. Edit copy globally in the elvix Console (no redeploy). The locale catalogs are the BOTTOM of the precedence chain.

## Full OpenAPI

Machine-readable spec lives at `/openapi.yaml`. Drop it into Scalar, Stoplight, or Swagger UI.

Agent-consumable role manifest lives at `/openapi.roles.json`. The hosted MCP server at `https://mcp.elvix.is` reads this to expose only `api`-labelled endpoints as typed tools.
