Skip to content

Hosted end-user accounts

Your product has its own users, and they are not WAMP accounts. Hosted accounts means WAMP stores that directory and mints the AI credentials for it, so a product with no backend of its own still gets real per-user sessions. After this page you can turn it on, sign a user in, refresh them, and see exactly which secret lives where.

The alternative is to run the directory yourself: keep your own Ed25519 private key, authenticate your own users, and sign the same tokens. Both paths produce byte-identical credentials, so the AI proxy cannot tell them apart. Starting hosted and moving to self-hosted later requires no migration — you register your own key and take over.

WAMP holds You hold
The user directory: email, bcrypt password hash, display name, disabled flag, last-seen time Nothing secret. Your shipped binary contains no signing key
An Ed25519 keypair for your app, private half encrypted at rest The kid of that key, which is public
Sign-up, sign-in, anonymous sessions, refresh, and self-service profile edits The UI around them
Metering, attributed to your publisher organization’s installation Your product

The last row is the commercial boundary: end-user AI usage is billed to the publisher organization’s own installation of your app. Your users do not need WAMP accounts, do not appear in your organization’s member list, and never become WAMP users.

Enabling generates the keypair and registers its public half as an app key. It is idempotent — calling it twice returns the existing state.

Terminal window
curl -X POST "$API/api/apps/acme-bot/hosted-accounts" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN"
# 201 {"success":true,"hostedAccounts":{"kid":"…","signUpOpen":true}}

All four management calls require organization.applications.manage in the publisher organization:

Method and path Effect
GET /api/apps/:slug/hosted-accounts {kid, signUpOpen}, or null when hosted accounts are off
POST /api/apps/:slug/hosted-accounts Enable. Idempotent
PATCH /api/apps/:slug/hosted-accounts {"signUpOpen": boolean}
DELETE /api/apps/:slug/hosted-accounts Disable

signUpOpen defaults to true and is the only gate on self-registration. A copy of the same flag may be baked into a shipped product file, but that copy is a client hint in a binary you no longer control; this one is what the server enforces.

There is one precondition that is easy to miss: your publisher organization must itself hold an active, AI-authorized installation of your app. That installation is what the minted tokens point at and what usage is billed to. Without it, every mint fails with installation_not_authorized, and an unauthenticated caller sees 404 app_not_found — hosted accounts being off is deliberately indistinguishable from the app not existing.

These live under /a/:slug and carry no platform session middleware at all. They are your users’ endpoints, not WAMP’s.

Method and path Auth Request Response
POST /a/:slug/signup None {email, password, name?} 201 {success, endUser: {id, email, name}, aiToken, expiresIn, refreshToken}
POST /a/:slug/signin None {email, password} {success, endUser, aiToken, expiresIn, refreshToken}
POST /a/:slug/anon None {deviceId} {success, endUserId, aiToken, expiresIn, refreshToken}
POST /a/:slug/refresh None {refreshToken} {success, aiToken, expiresIn, endUser?}
GET /a/:slug/me Authorization: Bearer <aiToken> {success, id, email?, name?, anonymous}
PATCH /a/:slug/me Authorization: Bearer <aiToken> {name?, password?}, at least one {success, id, email, name}

Field bounds are owned by the service, not by each route, so they are the same everywhere: email is trimmed and ≤254 characters, password is 8–100 characters, name is 1–100 characters, deviceId is 8–200 characters. Email is case-folded, so one address is one account.

Terminal window
curl -X POST "$API/a/acme-bot/signup" -H 'Content-Type: application/json' \
-d '{"email":"person@example.com","password":"correct horse battery","name":"Person"}'
{
"success": true,
"endUser": { "id": "c31f…", "email": "person@example.com", "name": "Person" },
"aiToken": "eyJhbGciOiJFZERTQSIsImtpZCI6IjRkY2…",
"expiresIn": 3600,
"refreshToken": "eyJhbGciOiJFZERTQSIsInR5cCI6ImF0K2p3dCI…"
}

Sign-in deliberately answers invalid_credentials for an unknown address, a missing password hash, and a wrong password alike, and only checks the disabled flag after the password verifies — so nothing here confirms whether an address is registered.

They are different credentials with different signers, and the difference is what makes a stolen refresh token useless at the AI proxy.

End-user AI token — signed with your app’s key:

Property Value
Header {"alg": "EdDSA", "kid": "<your app key kid>"}. There is no typ
iss Your app slug — not the platform issuer. This is the discriminator that keeps it out of the platform session path
sub The directory row id, or anon:<32 hex>
aud wamp-proxy
installation_id Your publisher organization’s installation UUID
iat, nbf, exp nbf equals iat; lifetime 3600 seconds
Presented as Authorization: Bearer <aiToken>

Refresh token — signed by the platform:

Property Value
Header {"alg": "EdDSA", "kid": "…", "typ": "at+jwt"}
iss The platform token issuer
aud wamp-app-refresh
sub The same end-user id
Claim app Your app slug. A refresh token minted for another app is refused here
Lifetime 30 days
Presented as The refreshToken field in a JSON body, never a header

The AI credential resolver rejects any token whose iss is the platform issuer before it checks a signature, so a refresh token can never be replayed as an AI credential. It can only be spent at /a/:slug/refresh.

Refresh does not rotate. POST /a/:slug/refresh returns a new aiToken and no new refreshToken — keep the one you have for its full 30 days. This differs from Sign in with WAMP, where refresh tokens do rotate; the two flows have different semantics on purpose, and code that assumes rotation here will throw away a token it still needs.

Refresh is also the enforcement point for disabling someone: the directory row is re-read on every refresh, so a disable in your console takes effect within one AI token lifetime — at most 3600 seconds — and answers user_disabled after that.

POST /a/:slug/anon gives an install a stable identity with no account and no directory row:

Terminal window
curl -X POST "$API/a/acme-bot/anon" -H 'Content-Type: application/json' \
-d '{"deviceId":"<opaque, ≥8 chars, generated once and stored locally>"}'
# {"success":true,"endUserId":"anon:9f2c…","aiToken":"…","expiresIn":3600,"refreshToken":"…"}

The id is anon: plus the first 32 hex characters of SHA-256("<appId>:<deviceId>"). Two consequences worth designing around: it is deterministic, so the same deviceId keeps its identity and its usage history across restarts; and it is salted with your app id, so the same device is a different person to every app and two apps can never collide on one identity key. Anonymous users never appear in the directory listing, because they are an identity key rather than a user record.

Your client holds the aiToken and presents it directly to the AI proxy. Your backend mints and refreshes; it never relays AI traffic. This is the Firebase custom-token shape, and it is why a product with no server still works.

The proxy is a WebSocket at /ws, not a REST completions endpoint. A JWT credential may travel in the Authorization header or as a ?token= query parameter on the upgrade; the HTTP tool routes accept the header only. On an app-principal socket you may also send end_user_id on an AI request — your own hash of your user, ≤64 characters, no personal data — which is what makes per-user usage show up in your app’s usage reports.

For a signed-in end user, two read surfaces exist:

Terminal window
# The caller's own profile.
curl -s "$API/a/acme-bot/me" -H "Authorization: Bearer $AI_TOKEN"
# The caller's own usage. This route requires an end-user token specifically;
# any other credential answers 403 end_user_token_required.
curl -s "$API/me/usage" -H "Authorization: Bearer $AI_TOKEN"

GET /a/:slug/me reports anonymous: true with just an id for an anonymous session, and the full profile otherwise — one shape for a client to model rather than two.

Terminal window
# List. Requires organization.applications.manage.
curl -s "$API/api/apps/acme-bot/end-users" -H "Authorization: Bearer $USER_ACCESS_TOKEN"
# {"success":true,"endUsers":[{"id","email","name","disabledAt","createdAt","lastSeenAt"}]}
# Create someone's account for them. NOT gated on signUpOpen.
curl -X POST "$API/api/apps/acme-bot/end-users" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"email":"person@example.com","password":"a good password","name":"Person"}'
# Disable or re-enable.
curl -X PATCH "$API/api/apps/acme-bot/end-users/c31f…" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"disabled":true}'

Creating a user from the console is intentionally not gated on signUpOpen: that flag governs strangers, and the owner of the app is not a stranger to it. That is what makes signUpOpen: false an invite-only mode rather than a locked door — there is no mail infrastructure here, so you set the password and hand it over.

The listing caps at 500 rows and is not paginated. Treat it as a console convenience, not an export: an app with more users than that needs its own search, built on your side.

Bodies are {"success": false, "error": "<code>"}.

Code Status Meaning
invalid_input 400 The body failed validation
invalid_credentials 401 Unknown address or wrong password — deliberately one answer
invalid_refresh 401 The refresh token is unknown, expired, for another app, or the account is gone
invalid_token 401 The aiToken does not verify, or belongs to another app
signup_closed 403 signUpOpen is false
user_disabled 403 The account is disabled
app_not_found 404 No such app, or hosted accounts are off, or the slug is unparseable — all one answer
email_taken 409 That address already has an account in this app
hosted_accounts_unavailable 503 Hosted accounts are not available on this deployment

Sign-up and sign-in share the authentication rate limit (20 burst, 10 per minute per client IP); anonymous sessions, refresh, and GET /a/:slug/me share the token-issuance limit (60 burst, 60 per minute). A rejection is 429 with a Retry-After header. Full table in Apps and credentials.

Nothing about the credential changes, so there is nothing to migrate:

  1. Generate your own Ed25519 keypair and register the public half with POST /api/apps/:slug/keys — see App identity.
  2. Mint end-user tokens yourself with the claims in The two tokens: alg: EdDSA and your kid in the header, no typ, iss your app slug, aud wamp-proxy, sub matching ^(anon:)?[A-Za-z0-9._:-]{1,128}$, and installation_id set to the exact customer installation UUID you are billing.
  3. Keep presenting them from the client, exactly as before.

Your own tokens can name any installation you are authorized for, which is the real difference: hosted accounts always bill your publisher organization, while a self-hosted backend can mint per-customer tokens against each customer’s own installation. If you sell to organizations that pay for their own usage, that is the reason to run the key yourself.