# Authentication Choose between the installation API key and the Ed25519 assertion flow, produce a credential /v1 accepts, refresh it before it expires, and learn which capability gates which call. Source: https://docs.cloud.vampikez.fun/start/authentication/ After this page you can choose the right credential for your integration, produce one that `/v1` accepts, refresh it before it expires, and say precisely which operations your installation is allowed to perform and why a `403` happened. ## One header scheme, two credentials Every `/v1` call carries the same header: ``` Authorization: Bearer ``` That is the only accepted scheme, and these properties hold whichever credential you put in it: - **Bearer only.** A request to `/v1` without an `Authorization: Bearer` header is `401 bearer_credential_required`, even if it carries a valid browser session cookie. `/v1` is a service API, not a browser backend. - **Bound to one installation, therefore one organization.** Sending `X-Wamp-Organization` alongside a bearer is `400 organization_header_not_allowed` — the credential already names exactly one tenant, and letting a header override it would be a tenancy hole. - **Not a user credential.** Sessions it creates are attributed to `createdBy: { kind: "app_installation" }`. It is not safe in a browser, a mobile app, or anywhere a customer can read it. - **Authority is live, never a snapshot.** Capabilities are re-resolved from the installation record on every request, so revoking the grant stops the credential immediately. What differs is how you get one, and there are two ways: | | **Installation API key** | **Installation token** | |---|---|---| | What it is | An opaque secret, `wamp_cloud_live_…`, shown exactly once | A JWT you mint from your own Ed25519 key | | Who creates it | An administrator of the organization, in WAMP Account | Your backend, per exchange | | Setup you build | None — one environment variable | Keypair, key registration, assertion signing, token cache | | Lifetime | Until revoked, or an optional expiry the administrator sets | **600 seconds**, then you mint another | | Organizations per credential | One | One per token; one key registration serves all of them | | Reach for it when | Your backend serves **one** organization: an internal integration, a single customer, an on-premise deployment | Your product is installed by **many** organizations, or policy forbids a long-lived secret at rest | **Start with the API key.** For the single-organization case it is the whole answer, and building the assertion flow first is work you did not need. A `wamp_cloud_…` key cannot authenticate as a user or call the AI proxy. A separate `wamp_ai_…` key can call inference with the `wamp.ai.invoke` grant. `/v1` also recognizes a human resource delegation bound to a signed-in person's Cloud grant, and the `native_oidc_access_token` credential class for the first-party native Authorization Code + PKCE client. Current Desktop Cloud uses a short-lived, memory-only delegation from the already signed-in WAMP Account; Account owns login and refresh. Desktop does not create a second Cloud login or refresh family. These credentials are not issued to third-party integrations. `GET /v1/me` verifies first-party identity. The native OIDC credential class can read identity even without product access; a human resource delegation must carry the Cloud audience and `wamp.cloud.access`. Installation credentials cannot call it. First-party clients use `/cloud` for host-specific operations; the web app presents a cookie and Desktop presents its delegated bearer. This distinction matters because a session created with human authority is attributed to the person rather than to an app installation. When such a session is created on a repository the organization has granted, it is owned by WAMP Cloud's own installation and reports `createdBy: { kind: "app_installation" }`. ## The installation API key The low-engineering path, and the one to use unless the table above sends you elsewhere. Everything happens once, in WAMP Account, and it is the administrator of the organization whose work you will run who does it — not you. Send them these steps. They need the `organization.integrations.manage` permission in that organization: 1. Open WAMP Account and go to **Installed apps**. 2. On the WAMP Cloud product, choose **Integrations**, then **New integration**. 3. Name the integration after the backend that will hold the key, and select the capabilities that backend needs — the same ids as in [What your installation may do](#what-your-installation-may-do). Select only what it uses; the set can be changed later without a new key. 4. Copy the key. **It is shown exactly once**, in that dialog, and cannot be retrieved afterwards — only replaced. That flow creates the installation for you: there is no app to register, no keypair, and no installation intent. What you receive is one string: ```bash export WAMP_CLOUD_API_KEY='wamp_cloud_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' curl -sS "$WAMP_API/v1/capabilities" -H "Authorization: Bearer $WAMP_CLOUD_API_KEY" ``` With the Node SDK it is the whole constructor: ```ts check function requiredEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Set ${name}`); return value; } const cloud = new WampCloud({ baseUrl: requiredEnv('WAMP_API'), apiKey: requiredEnv('WAMP_CLOUD_API_KEY'), }); ``` What to know about the key itself: - **Treat the whole string as the secret**, prefix included. The prefix names the resource (`cloud`) and the environment (`live`), and a key that does not match the deployment it is presented to fails as `401 invalid_or_expired_credential`. - **Store it in a secret manager**, load it at boot, never log it, never ship it to a client. Unlike a token, this one is worth stealing for as long as it lives. - **At most two keys are active at a time** per integration, which is what makes rotation possible: the administrator creates the second, you deploy it, they revoke the first. - **Losing it is recoverable, retrieving it is not.** The administrator replaces the key from the same screen; the replacement revokes the old one in the same step. - **There is no `expiresIn` to watch.** Nothing on your side refreshes. A `401` means the key was revoked, expired, or belongs to another deployment — not that it aged out, so do not build a re-mint loop around it. The rest of this page is the other credential. If the key is what you are using, skip to [What your installation may do](#what-your-installation-may-do). ### Use an installation key for AI inference An administrator can create an AI key in WAMP Account: **Installed apps → WAMP AI → Integrations → New integration**. Select `wamp.ai.invoke` and copy the `wamp_ai_live_…` key shown once. Send it to Account's AI gateway as a bearer; `WAMP_ACCOUNT` is the Account origin, which may differ from the Cloud origin. ```bash curl -sS "$WAMP_ACCOUNT/v1/models" \ -H "Authorization: Bearer $WAMP_AI_API_KEY" ``` ```ts const account = process.env.WAMP_ACCOUNT; const key = process.env.WAMP_AI_API_KEY; if (!account || !key) throw new Error('Set WAMP_ACCOUNT and WAMP_AI_API_KEY'); const response = await fetch(`${account}/v1/models`, { headers: { Authorization: `Bearer ${key}` }, }); if (!response.ok) throw new Error(`AI gateway returned ${response.status}`); const models = await response.json(); ``` This key is for inference only. It cannot call WAMP Cloud's `/v1` resource API or exchange for `/v1/runtime-credentials`; revoking it stops the next inference request. A `wamp_cloud_…` key cannot call the AI gateway. ## The installation token, for a product many organizations install Reach for this when one backend serves many customer organizations, or when a long-lived secret at rest is not acceptable. You register one Ed25519 public key against your app, and from then on you can mint a credential for any organization that installs you, without asking anyone to create a key per tenant. ### The chain ``` Ed25519 keypair you generate it; the private half never leaves your process │ ▼ register the public key your App (a slug + one or more key ids) │ ▼ a human administrator approves capabilities for their organization an Installation (a UUID you store per customer) │ ▼ sign a ≤600s assertion, POST it to /auth/app-installation-token an installation token (600s, audience wamp-cloud) │ ▼ /v1/... ``` Two origins are involved. The token exchange is served by WAMP **Account**; `/v1` is served by WAMP **Cloud**. In a single-origin deployment they are the same host; in a split deployment they are not. Keep them as two configuration values from the start — your operator tells you both. Every call in the four steps below goes to Account: ```bash export WAMP_ACCOUNT=https://api.vampikez.fun ``` 1. ### Generate a keypair One Ed25519 keypair per app, generated once. WAMP only ever sees the public half, so there is no shared secret to leak and nothing to rotate by support ticket. ```ts import { generateKeyPair, exportPKCS8, exportSPKI, exportJWK, calculateJwkThumbprint } from 'jose'; const { publicKey, privateKey } = await generateKeyPair('EdDSA', { extractable: true }); const privateKeyPem = await exportPKCS8(privateKey); // secret — your key manager const publicKeyPem = await exportSPKI(publicKey); // register this with WAMP const kid = await calculateJwkThumbprint(await exportJWK(publicKey)); ``` - `privateKeyPem` is PKCS8 PEM. Put it in a secret manager and load it into memory at boot. It must never ship in a distributed client. - `publicKeyPem` is SPKI PEM (`-----BEGIN PUBLIC KEY-----`). This is what you register. - `kid` is the RFC 7638 JWK thumbprint of the public key — the identical value WAMP derives when you register it, and the `kid` your signatures must carry. Register the public key against your app slug as a signed-in publisher with `organization.applications.manage`, through Account or its app-management API. An App assertion cannot register its own key. Rotation is additive: register a second public key, switch the `kid` you sign with, then revoke the first. A revoked key stops verifying at the next exchange. The Node SDK ships this as `generateAppKeypair()`; see [SDK](/reference/sdk/) for whether the package is installable for you yet. The snippet above uses `jose` directly and does not depend on it. 2. ### Sign an assertion The assertion is a JWT you sign with your private key. It proves your app's identity to WAMP Account, and it is what authenticates every call in the two steps below. | Part | Value | |---|---| | Header `alg` | `EdDSA` — Ed25519. Nothing else is accepted | | Header `kid` | Your registered key id (the JWK thumbprint) | | `iss` | Your app slug | | `sub` | Your app slug — the same value | | `aud` | Account's configured platform JWT issuer (`JWT_ISSUER`), supplied by the operator | | `iat` | Issue time, in seconds | | `exp` | Expiry. **`exp - iat` must be ≤ 600 seconds**, or the exchange fails | ```js title="assertion.mjs" import { SignJWT, importPKCS8 } from 'jose'; const key = await importPKCS8(process.env.WAMP_APP_PRIVATE_KEY, 'EdDSA'); export async function assertion() { const now = Math.floor(Date.now() / 1000); return new SignJWT({}) .setProtectedHeader({ alg: 'EdDSA', kid: process.env.WAMP_APP_KID }) .setIssuer(process.env.WAMP_APP_SLUG) .setSubject(process.env.WAMP_APP_SLUG) .setAudience(process.env.WAMP_AUD) .setIssuedAt(now) .setExpirationTime(now + 300) .sign(key); } // Running the file prints one assertion, which is what the shell tabs read. if (process.argv[1]?.endsWith('assertion.mjs')) process.stdout.write(await assertion()); ``` The payload is otherwise empty, and the file reads four values from the environment: `WAMP_APP_PRIVATE_KEY` (the PKCS8 PEM from step 1), `WAMP_APP_KID`, `WAMP_APP_SLUG`, and `WAMP_AUD` — the `aud` value the aside below explains how to obtain. Signing a fresh assertion per exchange costs nothing, so the TypeScript tabs below call `assertion()` again for every request. To follow the shell tabs, export one: ```bash export ASSERTION=$(node assertion.mjs) ``` Re-run that when a command answers `401 invalid_assertion` — an assertion past its `exp` fails exactly like a bad signature. The assertion's `aud` is checked against WAMP's own JWT issuer string. It is **not** the host you are calling and **not** the endpoint path: signing `aud` as `…/auth/app-installation-token` fails with `401 invalid_assertion` and looks exactly like a bad signature. Get the configured `JWT_ISSUER` from your operator (`https://api.vampikez.fun` on this deployment). Do not choose it from an unverified token or substitute the OIDC issuer ending in `/oauth2`. Clock skew between your host and WAMP is tolerated only to about 30 seconds, so keep your clock synchronized. 3. ### Get an installation An installation is one customer organization's approval of your app for one exact resource server. Nothing exists — and no token can be minted — until a human approves it. If the administrator installs your app themselves, they hand you the `installationId` and you are done. To drive the approval from your own product, create an installation intent, which is a signed request for a consent URL: ```bash INTENT=$(curl -sS -X POST "$WAMP_ACCOUNT/api/apps/installation-intents" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg a "$ASSERTION" '{ assertion: $a, resourceAudience: "wamp-cloud", capabilityIds: [ "wamp.cloud.access", "wamp.cloud.sessions:create", "wamp.cloud.sessions:read", "wamp.cloud.turns:submit" ] }')") INTENT_ID=$(printf '%s' "$INTENT" | jq -r '.intent.id') ``` ```ts import { assertion } from './assertion.mjs'; const { intent } = await fetch(`${process.env.WAMP_ACCOUNT}/api/apps/installation-intents`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assertion: await assertion(), resourceAudience: 'wamp-cloud', capabilityIds: [ 'wamp.cloud.access', 'wamp.cloud.sessions:create', 'wamp.cloud.sessions:read', 'wamp.cloud.turns:submit', ], }), }).then((response) => response.json()); ``` ```json { "success": true, "intent": { "id": "c1e4f9a2-5d3b-4a7e-8f10-2b6c9d4e7a15", "status": "pending", "expiresAt": "2026-08-11T09:15:00.000Z", "authorizeUrl": "https://accounts.example.com/install/c1e4f9a2-5d3b-4a7e-8f10-2b6c9d4e7a15" } } ``` Send the administrator to `authorizeUrl`. They choose the organization and approve the exact capability list you asked for. The intent is valid for **15 minutes**. Then poll for the result. Polling is a `POST` because it is authenticated by the same assertion, and reading an authorized result does not consume it, so a lost response is safe to retry: ```bash INSTALLATION=$(curl -sS -X POST \ "$WAMP_ACCOUNT/api/apps/installation-intents/$INTENT_ID/status" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg a "$ASSERTION" '{assertion: $a}')") # Empty until a human has approved; the exchange below needs it. export WAMP_INSTALLATION_ID=$(printf '%s' "$INSTALLATION" \ | jq -r '.intent.installationId // empty') ``` ```ts const status = await fetch( `${process.env.WAMP_ACCOUNT}/api/apps/installation-intents/${intent.id}/status`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assertion: await assertion() }), } ).then((response) => response.json()); ``` ```json { "success": true, "intent": { "id": "c1e4f9a2-5d3b-4a7e-8f10-2b6c9d4e7a15", "status": "authorized", "expiresAt": "2026-08-11T09:15:00.000Z", "authorizeUrl": "https://accounts.example.com/install/c1e4f9a2-5d3b-4a7e-8f10-2b6c9d4e7a15", "installationId": "8a0f1c6b-92d4-4e73-b5a1-0d3e7f2c9b48" } } ``` `status` is `pending`, `authorized`, or `expired`; `installationId` appears only on `authorized`. Store it against your customer — it is the tenancy key for everything that follows. Every id in `capabilityIds` must be a capability that an app installation is allowed to hold. Asking for a human-only capability fails the whole intent with `installation_not_authorized`, so request from the list [below](#what-your-installation-may-do), and request only what you use. 4. ### Exchange it for a token The assertion travels in a **body field named `assertion`** — not in a header. The body is validated strictly: three required fields, one optional fourth, and nothing else. ```bash curl -sS -X POST "$WAMP_ACCOUNT/auth/app-installation-token" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg a "$ASSERTION" --arg i "$WAMP_INSTALLATION_ID" \ '{assertion: $a, installationId: $i, resourceAudience: "wamp-cloud"}')" ``` ```ts const { token, expiresIn } = await fetch( `${process.env.WAMP_ACCOUNT}/auth/app-installation-token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assertion: await assertion(), installationId: process.env.WAMP_INSTALLATION_ID, resourceAudience: 'wamp-cloud', }), } ).then((response) => response.json()); ``` ```json { "success": true, "token": "eyJhbGciOiJFZERTQSIsImtpZCI6…", "expiresIn": 600 } ``` | Field | Required | Meaning | |---|---|---| | `assertion` | yes | The signed JWT from the previous step, 20–4096 characters | | `installationId` | yes | UUID of the installation this token acts for | | `resourceAudience` | yes | The resource server the token is for. For the Cloud API it is always `wamp-cloud` | | `setupAuthorityId` | no | UUID. The opaque setup authority an authorized installation intent handed you. Send it only when completing that handshake; Account binds the live authorizing membership into the token itself, so you cannot supply a membership id | Failures on this endpoint use Account's envelope, `{ "success": false, "error": "" }`, rather than the `/v1` error shape: | Status | `error` | Cause | |---|---|---| | `400` | `invalid_request` | A missing, extra, or malformed field. This body also carries an `issues` array naming what failed validation | | `401` | `invalid_assertion` | Signature, claims, `aud`, or expiry did not verify | | `401` | `unknown_app_or_key` | No app matches `iss`, or no live key matches `kid` | | `401` | `assertion_too_long_lived` | `exp - iat` exceeds 600 seconds, or `iat`/`exp` is missing | | `403` | `app_suspended` | The app is suspended | | `403` | `installation_not_found` | Not an active installation of this app | | `403` | `installation_revoked` | The installation existed and was revoked. Nothing to retry; the organization must install the app again | | `403` | `resource_server_not_found` | No active resource server for that `resourceAudience` | | `403` | `installation_not_authorized` | The organization has not approved this app for that resource server | | `429` | rate limited | Token issuance is rate limited per IP. Cache tokens; do not mint per request | | `500` | `exchange_failed` | The exchange itself failed unexpectedly. Retry with backoff | ### Refresh before expiry The token lives 600 seconds and many runs take longer, so refreshing is part of normal operation rather than an error path. None of this applies to an installation API key, which has nothing to refresh. - **Cache per installation.** Key the cache by `installationId`. A multi-tenant backend holds one token per active customer, never one globally. - **Refresh on a margin.** Re-mint when less than a minute of life remains, using the `expiresIn` from the response rather than a hardcoded number. - **Re-mint once on `401`.** If a `/v1` call returns `401 invalid_or_expired_credential`, discard the cached token, mint a new one, and retry the request exactly once. Never loop. - **Never re-mint on `403`.** `403 insufficient_scope` and `403 cloud_access_denied` are authorization outcomes; a fresh token has identical authority. Surface them to whoever can fix the grant. - **Never persist a token.** Not in a database, not in a log, not in a trace. It is cheaper to mint another one. ## What your installation may do Capabilities are the authorization model, and they are the same set whichever credential you hold: an administrator chooses them when creating an integration, or approves the list your installation intent asked for. Each one gates specific operations. `wamp.cloud.access` is the gate on the credential itself: every `/v1` request is checked against it before routing, and a credential without it gets `403 cloud_access_denied` on everything. The rest gate individual operations: | Capability | What it gates | |---|---| | `wamp.cloud.access` | Using WAMP Cloud at all. Checked on every request | | `wamp.cloud.sessions:create` | `PUT` and `PATCH` a session — and `GET /v1/capabilities` | | `wamp.cloud.sessions:read` | Reading sessions, turns, runs, events, interactions, artifacts, publications, and the repository review | | `wamp.cloud.sessions:archive` | `DELETE` (archive) a session | | `wamp.cloud.turns:submit` | `PUT` a turn, which is how all work is submitted, and `POST /v1/transcriptions` | | `wamp.cloud.runs:cancel` | Requesting cancellation of a run | | `wamp.cloud.approvals:respond` | Answering a live approval with Session access; sensitive, grantable to humans and App installations | | `wamp.cloud.repositories:read` | Discovering usable repositories (`GET /v1/repositories`) and the grants behind them | | `wamp.cloud.publications:create` | Opening a pull request from a session's reviewed tree. Sensitive | | `wamp.cloud.publications:merge` | Merging a publication at its exact reviewed head. Sensitive | | `wamp.cloud.runtime-releases:probe` | Activating or deactivating a bounded runtime-release probe for this installation's new placements. Sensitive, APP_INSTALLATION only | When a call needs one you do not hold, the response names it: ```json { "error": "insufficient_scope", "requiredScope": "wamp.cloud.turns:submit" } ``` The field is `requiredScope`, singular, and its value is a capability id — which makes it the string to put in the message you show an administrator. `GET /v1/capabilities` requires `wamp.cloud.sessions:create`, not `sessions:read`. An integration that only reads sessions therefore cannot call the discovery endpoint at all. If you need a read-only credential *and* model discovery, request `sessions:create` as well and do not create sessions with it. The contract declares fourteen capabilities in total. The ten above are the ones an app installation can be granted. The other four — `wamp.cloud.sessions:manage` (opening a session an installed app created, by taking operator access on it), `wamp.cloud.accounts:manage` (enrolling runtime accounts), `wamp.cloud.repositories:manage` (connecting organization installations and changing application grants), and `wamp.cloud.compute:manage` (adding, draining and removing the machines the organization runs its work on) — can only be held by a human signed in to WAMP Cloud. They are administration surfaces, not part of an integration's request, and asking for one in an installation intent fails the intent. `wamp.cloud.publications:create`, `wamp.cloud.publications:merge`, and `wamp.cloud.runtime-releases:probe` are marked sensitive. The first two can write to a customer's repository; the third is a release-authority override, not a session input. Expect them to receive more scrutiny during approval, and request them only if you need them. Most integrations never ask for the probe. ## Related - [Integrate WAMP Cloud](/start/integrate/) — the exchange plus a complete run, end to end. - [Drive Cloud from a backend](/guides/from-your-backend/) — a token cache with refresh-on-`401`, and what else belongs in your database. - [Connect a repository](/guides/connect-a-repository/) — how an administrator creates a grant and how your installation discovers it. - [Authentication operations](/api/operations/tags/authentication/) — the generated reference for the three exchange endpoints. - [Errors](/reference/errors/) — every `/v1` error code and whether it is retryable.