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.
What it gives you
Section titled “What it gives you”| 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.
Turn it on
Section titled “Turn it on”Enabling generates the keypair and registers its public half as an app key. It is idempotent — calling it twice returns the existing state.
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.
The end-user endpoints
Section titled “The end-user endpoints”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.
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.
The two tokens
Section titled “The two tokens”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.
Anonymous sessions
Section titled “Anonymous sessions”POST /a/:slug/anon gives an install a stable identity with no account and no
directory row:
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.
What your app’s code sees
Section titled “What your app’s code sees”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:
# 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.
Managing the directory
Section titled “Managing the directory”# 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.
Errors
Section titled “Errors”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.
Moving to your own backend
Section titled “Moving to your own backend”Nothing about the credential changes, so there is nothing to migrate:
- Generate your own Ed25519 keypair and register the public half with
POST /api/apps/:slug/keys— see App identity. - Mint end-user tokens yourself with the claims in
The two tokens:
alg: EdDSAand yourkidin the header, notyp,issyour app slug,audwamp-proxy,submatching^(anon:)?[A-Za-z0-9._:-]{1,128}$, andinstallation_idset to the exact customer installation UUID you are billing. - 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.