Authentication
After this page you can produce a credential 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 scheme, one credential
Section titled “One scheme, one credential”Every /v1 call carries the same header:
Authorization: Bearer <installation token>That is the only accepted scheme. The details that matter:
- Bearer only. A request to
/v1without anAuthorization: Bearerheader is401 bearer_credential_required, even if it carries a valid browser session cookie./v1is a service API, not a browser backend. - Short-lived. A token expires 600 seconds after it is minted. There is no refresh token; you mint another one from your key.
- Bound to one installation, therefore one organization. Sending
X-Wamp-Organizationalongside a bearer is400 organization_header_not_allowed— the token already names exactly one tenant, and letting a header override it would be a tenancy hole. - Not a user credential. Sessions your token 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.
The chain
Section titled “The chain”Ed25519 keypair you generate it; the private half never leaves your process │ ▼ register the public keyyour App (a slug + one or more key ids) │ ▼ a human administrator approves capabilities for their organizationan Installation (a UUID you store per customer) │ ▼ sign a ≤600s assertion, POST it to /auth/app-installation-tokenan 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.
-
Generate a keypair
Section titled “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.
import { generateKeyPair, exportPKCS8, exportSPKI, exportJWK, calculateJwkThumbprint } from 'jose';const { publicKey, privateKey } = await generateKeyPair('EdDSA', { extractable: true });const privateKeyPem = await exportPKCS8(privateKey); // secret — your key managerconst publicKeyPem = await exportSPKI(publicKey); // register this with WAMPconst kid = await calculateJwkThumbprint(await exportJWK(publicKey));privateKeyPemis PKCS8 PEM. Put it in a secret manager and load it into memory at boot. It must never ship in a distributed client.publicKeyPemis SPKI PEM (-----BEGIN PUBLIC KEY-----). This is what you register.kidis the RFC 7638 JWK thumbprint of the public key — the identical value WAMP derives when you register it, and thekidyour signatures must carry.
Registering the public key against your app slug is done by the app’s owner while signed in to WAMP Account, not with a bearer token. Rotation is additive: register a second public key, switch the
kidyou sign with, then revoke the first. A revoked key stops verifying at the next exchange.The Node SDK ships this as
generateAppKeypair(); see SDK for whether the package is installable for you yet. The snippet above usesjosedirectly and does not depend on it. -
Get an installation
Section titled “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
installationIdand you are done. To drive the approval from your own product, create an installation intent, which is a signed request for a consent URL:Terminal window 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"]}')"{"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
POSTbecause it is authenticated by the same assertion, and reading an authorized result does not consume it, so a lost response is safe to retry:Terminal window 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}')"{"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"}}statusispending,authorized, orexpired;installationIdappears only onauthorized. Store it against your customer — it is the tenancy key for everything that follows.Every id in
capabilityIdsmust be a capability that an app installation is allowed to hold. Asking for a human-only capability fails the whole intent withinstallation_not_authorized, so request from the list below, and request only what you use. -
Sign an assertion
Section titled “Sign an assertion”The assertion is a JWT you sign with your private key. It proves your app’s identity for exactly one exchange.
Part Value Header algEdDSA— Ed25519. Nothing else is acceptedHeader kidYour registered key id (the JWK thumbprint) issYour app slug subYour app slug — the same value audThe platform’s JWT issuer identifier iatIssue time, in seconds expExpiry. exp - iatmust be ≤ 600 seconds, or the exchange failsimport { SignJWT, importPKCS8 } from 'jose';const key = await importPKCS8(process.env.WAMP_APP_PRIVATE_KEY, 'EdDSA');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.TOKEN_ISSUER).setIssuedAt(now).setExpirationTime(now + 300).sign(key);}The payload is otherwise empty. Signing a fresh assertion per exchange costs nothing, so do not cache one.
-
Exchange it for a token
Section titled “Exchange it for a token”The assertion travels in a body field named
assertion— not in a header. The body is validated strictly and accepts exactly these three fields:Terminal window 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"}')"{ "success": true, "token": "eyJhbGciOiJFZERTQSIsImtpZCI6…", "expiresIn": 600 }Field Required Meaning assertionyes The signed JWT from the previous step, 20–4096 characters installationIdyes UUID of the installation this token acts for resourceAudienceyes The resource server the token is for. For the Cloud API it is always wamp-cloudFailures on this endpoint use Account’s envelope,
{ "success": false, "error": "<code>" }, rather than the/v1error shape:Status errorCause 400invalid_requestA missing, extra, or malformed field 401invalid_assertionSignature, claims, aud, or expiry did not verify401unknown_app_or_keyNo app matches iss, or no live key matcheskid401assertion_too_long_livedexp - iatexceeds 600 seconds, oriat/expis missing403app_suspendedThe app is suspended 403installation_not_foundNot an active installation of this app 403resource_server_not_foundNo active resource server for that resourceAudience403installation_not_authorizedThe organization has not approved this app for that resource server 429rate limited Token issuance is rate limited per IP. Cache tokens; do not mint per request
Refresh before expiry
Section titled “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.
- 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
expiresInfrom the response rather than a hardcoded number. - Re-mint once on
401. If a/v1call returns401 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_scopeand403 cloud_access_deniedare 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
Section titled “What your installation may do”Capabilities are the authorization model. They are approved once, at installation time, and 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 |
wamp.cloud.runs:cancel |
Requesting cancellation of a run |
wamp.cloud.repositories:read |
Discovering the repository grants an administrator has assigned to your installation |
wamp.cloud.publications:create |
Opening a pull request from a session’s reviewed tree |
wamp.cloud.publications:merge |
Merging a publication at its exact reviewed head |
When a call needs one you do not hold, the response names it:
{ "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.
The contract declares twelve capabilities in total. The nine above are the ones an
app installation can be granted. The other three —
wamp.cloud.sessions:manage (sharing a session with organization members),
wamp.cloud.accounts:manage (enrolling runtime accounts), and
wamp.cloud.repositories:manage (connecting and revoking repository access) — 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 and wamp.cloud.publications:merge are marked
sensitive in the contract, because between them they can write to a customer’s
repository. Expect them to receive more scrutiny during approval, and request
them only if you publish.
Related
Section titled “Related”- Quickstart — the exchange plus a complete run, end to end.
- Drive Cloud from a backend — a token cache with
refresh-on-
401, and what else belongs in your database. - Connect a repository — how an administrator creates a grant and how your installation discovers it.
- Authentication operations — the generated reference for the three exchange endpoints.
- Errors — every
/v1error code and whether it is retryable.