Apps and credentials
This page is the credential reference. For each credential you can actually obtain: how you get it, the exact wire format, how long it lives, what authority it carries, and how to revoke it. Use the app assertion exchange for a third-party backend.
Three values come from the deployment, not from this page:
| Placeholder | What it is | How to get it |
|---|---|---|
$API |
The Account API origin, e.g. https://api.example.com |
From the operator |
$ISSUER |
The OIDC issuer, ending in /oauth2 |
From the operator |
$TOKEN_ISSUER |
The issuer of platform-signed non-OIDC tokens and the required aud of an app assertion |
https://api.vampikez.fun here; confirm the deployment’s configured JWT_ISSUER elsewhere. Never trust an unverified token to choose it |
Every token WAMP signs uses EdDSA (Ed25519). There are no HMAC-signed tokens and no client secrets anywhere in the model.
The credentials you can hold
Section titled “The credentials you can hold”| Credential | Represents | Format |
|---|---|---|
| User access token | One human, their whole account surface | Authorization: Bearer <jwt> |
| User refresh token | The right to mint the above | JSON body field |
| OIDC access and ID token | One human who consented to your client, in one organization | Authorization: Bearer <jwt> |
| App assertion | Your app’s publisher identity | Request body field assertion |
| App installation access token | Your app acting inside one customer organization | Authorization: Bearer <jwt> |
| End-user AI token | One end user your App backend authenticates | Authorization: Bearer <jwt> |
User session tokens
Section titled “User session tokens”curl -X POST "$API/auth/login" -H 'Content-Type: application/json' \ -d '{"email":"person@example.com","password":"…"}'# {"success":true,"user":{…},"accessToken":"…","refreshToken":"…","expiresIn":…}| Property | Value |
|---|---|
| Header | Authorization: Bearer <jwt> |
| JWT header | {"alg": "EdDSA", "kid": "…", "typ": "at+jwt"} |
| Claims | iss is $TOKEN_ISSUER, sub is the user id, sid is the opaque session id, plus userId and email |
| Lifetime | 15 minutes by default, set by the operator |
| Authority | User-authenticated management routes, subject to live membership and route-specific tenant-context requirements |
| Revocation | POST /auth/logout deletes the exact refresh session identified by sid, including after access-token refresh. An already-issued access JWT remains valid until expiry |
The refresh token is a separate JWT that travels in a JSON body, never a header:
curl -X POST "$API/auth/refresh" -H 'Content-Type: application/json' \ -d '{"refreshToken":"…"}'It has two independent clocks. An absolute cap is baked into the token — 30 days by default — and a sliding idle window on the session row, 7 days by default, moves forward on every refresh. An active session dies at the absolute cap; an idle one dies one idle window after its last use.
This is the credential the developer-facing routes on $API want. It is not the
same thing as an OIDC access token: those carry aud equal to your client_id
and are meant for your own resource server, not for $API management routes.
Presenting the wrong one gets you 401 {"error": "Invalid or expired token"}.
App identity
Section titled “App identity”An app is the publisher-side identity: one row in a publisher organization, with a slug, and one or more public keys. There is no app secret. You generate an Ed25519 keypair, upload the public half, and keep the private half.
openssl genpkey -algorithm ed25519 -out app.keyopenssl pkey -in app.key -pubout -out app.pub # SPKI PEM
curl -X POST "$API/api/apps" \ -H "Authorization: Bearer $USER_ACCESS_TOKEN" -H 'Content-Type: application/json' \ -d '{"orgId":"8c1b…","slug":"acme-bot","name":"Acme Bot"}'
curl -X POST "$API/api/apps/acme-bot/keys" \ -H "Authorization: Bearer $USER_ACCESS_TOKEN" -H 'Content-Type: application/json' \ -d '{"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2Vw…\n-----END PUBLIC KEY-----\n"}'# 201 {"success":true,"key":{"kid":"…","revokedAt":null,"createdAt":"…"}}Creating an app and registering keys both require
organization.applications.manage in the publisher organization. slug is 2–64
characters matching ^[a-z0-9][a-z0-9-]*[a-z0-9]$, and these slugs are reserved
and can never be claimed: wamp, wamp-cloud, wamp-ai, wamp-core,
wamp-engine, api, app, apps, auth, admin, marketplace.
kid is the RFC 7638 JWK thumbprint of the public key, so you can compute it
locally and never have to read it back. publicKeyPem must contain
-----BEGIN PUBLIC KEY----- and be ≤4096 characters.
Revoke a key with DELETE /api/apps/:slug/keys/:kid. Revocation is checked live
on every exchange, so it bites immediately. Register the replacement key before
revoking the old one — an app with no unrevoked key cannot authenticate at all,
and a private_key_jwt OAuth client on that app stops working too.
The assertion
Section titled “The assertion”The assertion is a short-lived JWT you sign yourself. It proves publisher
identity only — never tenant authority — and it travels in a request body
field named assertion, never in a header.
| Part | Required value |
|---|---|
Header alg |
EdDSA |
Header kid |
A registered, unrevoked kid for this app |
iss |
Your app slug |
sub |
Your app slug — it must equal iss |
aud |
$TOKEN_ISSUER |
iat, exp |
Both required, and exp - iat must be ≤ 600 seconds |
Clock tolerance is 30 seconds. A missing or oversized lifetime is its own error
(assertion_too_long_lived), distinct from a bad signature
(invalid_assertion) and from an unknown app or key (unknown_app_or_key).
App installation access tokens
Section titled “App installation access tokens”This is how your backend acts as itself inside one customer organization. You
exchange an assertion plus one installationId plus one exact resource audience
for a token scoped to precisely that.
curl -X POST "$API/auth/app-installation-token" \ -H 'Content-Type: application/json' \ -d '{ "assertion": "<jwt you just signed>", "installationId": "5b7e…", "resourceAudience": "wamp-ai" }'# {"success":true,"token":"<jwt>","expiresIn":600}The request is strict — those three fields and nothing else. installationId
must be a UUID; resourceAudience is 1–128 printable ASCII characters naming one
resource server.
| Property | Value |
|---|---|
| Header | Authorization: Bearer <jwt> |
| JWT header | {"alg": "EdDSA", "kid": "…", "typ": "at+jwt"} |
iss |
$TOKEN_ISSUER |
aud |
Exactly the resourceAudience you asked for — one string, never an array |
sub |
app-installation-access:<installationId> |
| Claims | token_use: "app_installation_access", org_id, installation_id, app_id, resource_installation_id, resource_app_id, resource_server_id, authorization_version, capabilities (sorted, deduplicated), jti, and nbf equal to iat |
| Lifetime | 600 seconds, not configurable |
| Authority | The intersection of the signed capabilities and the live grant graph, re-resolved on every request. The signed array is a ceiling, not a grant |
| Revocation | Revoke the installation, the capability grant, or the app key. Any of the three ends it at the next request |
Because the ceiling is re-checked live, you do not need to shorten the TTL to
make revocation prompt, and you should not cache authority decisions derived from
capabilities.
Failures split by kind: 401 for invalid_assertion, unknown_app_or_key, and
assertion_too_long_lived; 403 for app_suspended, installation_not_found,
resource_server_not_found, and installation_not_authorized.
Getting an installationId
Section titled “Getting an installationId”An installation is created when a customer organization approves your app. You open a consent handoff with your own assertion, send a human to it, then poll.
# 1. You: open the intent. Requires only your assertion.curl -X POST "$API/api/apps/installation-intents" \ -H 'Content-Type: application/json' \ -d '{ "assertion": "<jwt>", "resourceAudience": "wamp-ai", "capabilityIds": ["wamp.ai.invoke"] }'# 201 {"success":true,"intent":{"id":"9a4d…","status":"pending",# "expiresAt":"…","authorizeUrl":"https://account.example.com/install/9a4d…"}}Send the customer to authorizeUrl. The unguessable intent id is the
invitation, so treat the URL as a secret. A pending intent lives 15 minutes.
# 2. The customer's admin, in their own session, approves it for their org.curl -X POST "$API/api/apps/installation-intents/9a4d…/authorize" \ -H "Authorization: Bearer $CUSTOMER_ACCESS_TOKEN" \ -H 'Content-Type: application/json' -d '{"orgId":"8c1b…"}'
# 3. You: poll. Reading an authorized intent does not consume it.curl -X POST "$API/api/apps/installation-intents/9a4d…/status" \ -H 'Content-Type: application/json' -d '{"assertion":"<fresh jwt>"}'# {"success":true,"intent":{"status":"authorized","installationId":"5b7e…", …}}After authorization the record is readable for 60 minutes, which is your
window to collect the installationId — store it, it is permanent. Polling is
idempotent, so a lost response cannot strand you. A second authorization by a
different user or organization answers 409 installation_intent_already_authorized; an expired intent answers 410.
capabilityIds is 0–100 ids, each 3–128 characters. An empty list is accepted
only for an identity-only resource audience owned by the App signing the
request; it cannot install another publisher’s App without explicit capability
grants. For a non-empty list, you may only request capabilities whose definition
allows an app installation to hold them — some are human-only, and asking for
one answers installation_not_authorized. Before a customer sees the screen,
check which capabilities the resource server you target actually offers to app
installations: wamp.ai.invoke for the AI proxy, and the wamp.cloud.* set documented at
docs.cloud.vampikez.fun.
Credentials you will not see
Section titled “Credentials you will not see”These exist in the platform and you may notice them in logs or token dumps. None of them are obtainable by a third-party developer, and none are a contract you should build against.
| Credential | Why it is not yours |
|---|---|
Browser session cookies (__Host-wamp_access, __Secure-wamp_refresh) |
Issued only to same-origin requests from Account Center and first-party product web hosts |
Extension token (aud: ext:<extensionId>) |
Minted by the desktop host for its own extensions; the trust boundary is the Electron main process |
Engine connection token (aud: wamp-engine) |
The desktop host’s least-privilege credential for connecting to an engine core |
Installation proxy token (sub: app-installation-proxy:…) |
Mintable only through the private internal delegation plane. Third parties use the installation access token above |
Workload assertions to /internal/v1/* |
The private trust plane between first-party resource servers and the Account process |
Error codes on the app routes
Section titled “Error codes on the app routes”Bodies are {"success": false, "error": "<code>", "message": "…"}.
| Code | Status | Meaning |
|---|---|---|
invalid_request |
400 | The body failed validation |
slug_reserved |
400 | The slug is in the platform-reserved list |
invalid_public_key |
400 | Not an acceptable SPKI PEM Ed25519 public key |
invalid_assertion |
401 | Bad signature, wrong iss/sub/aud, or a malformed header |
unknown_app_or_key |
401 | No app with that slug, or no unrevoked key with that kid |
assertion_too_long_lived |
401 | exp - iat exceeded 600 seconds, or iat/exp were missing |
permission_denied |
403 | You lack organization.applications.manage on the publisher organization |
app_suspended |
403 | The app is suspended; nothing it signs is accepted |
app_not_found |
404 | Unknown slug, or one you do not administer |
key_not_found |
404 | Unknown kid |
installation_not_found |
404 | Unknown installation for this app |
installation_intent_not_found |
404 | Unknown intent id |
slug_taken |
409 | Another app already has that slug |
key_already_registered |
409 | That public key is already on the app |
installation_intent_already_authorized |
409 | Someone already authorized this intent |
installation_intent_expired |
410 | Past the intent TTL |
Rate limits
Section titled “Rate limits”Limits are per client IP, token-bucket, and the limiter fails open if it errors — so treat them as a floor, not a guarantee.
| Bucket | Burst | Sustained | Applies to |
|---|---|---|---|
| Authentication | 20 | 10 per minute | POST /auth/login, POST /auth/change-password |
| Token issuance | 60 | 60 per minute | POST /auth/refresh, POST /auth/app-installation-token, app key, OAuth client, installation intents |
A rejection is 429 with a Retry-After header and
{"success": false, "error": "Too many requests. Please try again later.", "retryAfter": <seconds>}.
One more surface: usage
Section titled “One more surface: usage”Two read-only endpoints report metered AI usage for your app, both requiring
organization.applications.manage:
curl -s "$API/api/apps/acme-bot/usage?limit=100" -H "Authorization: Bearer $USER_ACCESS_TOKEN"curl -s "$API/api/apps/acme-bot/usage/summary?from=2026-08-01" -H "Authorization: Bearer $USER_ACCESS_TOKEN"The detail endpoint takes limit (≤100), before, from, to, and
endUserId, and pages with before. Attribution by endUserId only appears
for signed end-user subjects, or when an app-level credential sends user
or safety_identifier on the HTTP AI request.