Skip to content

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.

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 /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.
  • 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-Organization alongside a bearer is 400 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.
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.

  1. 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 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.

    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 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 for whether the package is installable for you yet. The snippet above uses jose directly and does not depend on it.

  2. 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:

    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 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:

    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"
    }
    }

    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, and request only what you use.

  3. The assertion is a JWT you sign with your private key. It proves your app’s identity for exactly one exchange.

    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 The platform’s JWT issuer identifier
    iat Issue time, in seconds
    exp Expiry. exp - iat must be ≤ 600 seconds, or the exchange fails
    import { 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.

  4. 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
    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

    Failures on this endpoint use Account’s envelope, { "success": false, "error": "<code>" }, rather than the /v1 error shape:

    Status error Cause
    400 invalid_request A missing, extra, or malformed field
    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 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

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 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.

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.