# Apps and credentials Every credential a WAMP developer can hold — how to obtain it, its exact header format, lifetime, authority, and how to revoke it. Source: https://docs.vampikez.fun/identity/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 | Credential | Represents | Format | |---|---|---| | [User access token](#user-session-tokens) | One human, their whole account surface | `Authorization: Bearer ` | | [User refresh token](#user-session-tokens) | The right to mint the above | JSON body field | | [OIDC access and ID token](/identity/sign-in-with-wamp/) | One human who consented to your client, in one organization | `Authorization: Bearer ` | | [App assertion](#app-identity) | Your app's publisher identity | Request body field `assertion` | | [App installation access token](#app-installation-access-tokens) | Your app acting inside one customer organization | `Authorization: Bearer ` | | [End-user AI token](/ship/own-backend/) | One end user your App backend authenticates | `Authorization: Bearer ` | ## User session tokens ```bash 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 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: ```bash 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 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. ```bash openssl genpkey -algorithm ed25519 -out app.key openssl 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 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 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. ```bash curl -X POST "$API/auth/app-installation-token" \ -H 'Content-Type: application/json' \ -d '{ "assertion": "", "installationId": "5b7e…", "resourceAudience": "wamp-ai" }' # {"success":true,"token":"","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 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:` | | 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 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. ```bash # 1. You: open the intent. Requires only your assertion. curl -X POST "$API/api/apps/installation-intents" \ -H 'Content-Type: application/json' \ -d '{ "assertion": "", "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**. ```bash # 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":""}' # {"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](https://docs.cloud.vampikez.fun). ## 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:`) | 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 Bodies are `{"success": false, "error": "", "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 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": }`. ## One more surface: usage Two read-only endpoints report metered AI usage for your app, both requiring `organization.applications.manage`: ```bash 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.