Skip to content

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 kill it. It also contains the two worked examples that are hard to reconstruct from a table — the app assertion exchange, and API key creation and rotation.

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 iss value on every non-OIDC platform token, and the required aud of an app assertion Decode any token the platform issued you and read iss

Every token WAMP signs uses EdDSA (Ed25519). There are no HMAC-signed tokens and no client secrets anywhere in the model.

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>
Organization API key Your own code, billed to an organization Authorization: Bearer wamp_sk_…
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 of your product Authorization: Bearer <jwt>
Webhook signing secret Proof that a callback came from WAMP Wamp-Signature header on inbound requests
Terminal window
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, plus userId and email
Lifetime 15 minutes by default, set by the operator
Authority Everything that human can do, in every organization they belong to
Revocation POST /auth/logout deletes the session row; otherwise expiry

The refresh token is a separate JWT that travels in a JSON body, never a header:

Terminal window
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"}.

An API key lets your own scripts and servers reach the metered AI proxy without embedding anyone’s password. The secret is wamp_sk_ followed by 32 CSPRNG bytes in base62.

Terminal window
curl -X POST "$API/auth/api-keys" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Production"}'
{
"success": true,
"key": "wamp_sk_7Qx…",
"apiKey": {
"id": "0f3a…",
"orgId": "8c1b…",
"name": "Production",
"keyPrefix": "wamp_sk_7Qx…",
"scopes": ["ai:invoke"],
"lastUsedAt": null,
"expiresAt": null,
"revokedAt": null,
"createdAt": "2026-08-11T09:00:00.000Z"
}
}

key appears exactly once, in this response. Only a SHA-256 hash is stored, so nothing can recover it later. keyPrefix is the first 12 characters — wamp_sk_ plus four — and is safe to log and display.

Property Value
Header Authorization: Bearer wamp_sk_…. Header only — a service key in a ?token= query parameter is refused, so it cannot leak into access logs
Name 2–60 characters
Lifetime No expiry. expiresAt is nullable and is never set at creation
Authority scopes: ["ai:invoke"], and nothing else. A leaked key can drive metered model calls and cannot manage the organization
Revocation DELETE /auth/api-keys/:id, effective on the next verification

Two live conditions apply on every use, which is what makes an unexpiring key tolerable: the human who created it must still hold an ACTIVE membership in the key’s organization, and that organization must still grant them wamp.ai.invoke. Offboard the creator and their keys stop working. Revoke the AI product installation and every key in the organization stops working.

Creating a key needs a tenant context on the caller — a token bound to one organization — otherwise you get 403 {"success": false, "error": "organization_context_required"}. It also needs wamp.ai.invoke in that organization, which is a resource capability rather than an organization permission: even a full owner gets 403 permission_denied without it. See Two namespaces in one array.

Rotation is create-then-revoke; there is no atomic rotate call and no grace period, because the old key stays valid until you delete it:

Terminal window
# 1. Mint the replacement and deploy it.
curl -X POST "$API/auth/api-keys" -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H 'Content-Type: application/json' -d '{"name":"Production 2026-08"}'
# 2. Confirm nothing is still using the old one.
curl -s "$API/auth/api-keys" -H "Authorization: Bearer $USER_ACCESS_TOKEN"
# → each record carries lastUsedAt, stamped at most once per key per minute
# 3. Revoke it.
curl -X DELETE "$API/auth/api-keys/0f3a…" -H "Authorization: Bearer $USER_ACCESS_TOKEN"

Listing and revoking are permission-scoped rather than permission-gated. With organization.integrations.read you see every key in the organization; without it you see only the keys you created. With organization.integrations.manage you can revoke any key; without it, only your own. Revoking a key that is not yours and not in reach answers 404.

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.

Terminal window
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, forge, 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 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).

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.

Terminal window
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, installation_revoked, resource_server_not_found, and installation_not_authorized.

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.

Terminal window
# 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.

Terminal window
# 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 1–100 ids, each 3–128 characters. 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.

WAMP calls your endpoint for Cloud event families. The secret is returned once, at registration.

Terminal window
curl -X POST "$API/api/apps/acme-bot/installations/5b7e…/webhooks" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://hooks.example.com/wamp","families":["run","interaction"]}'
# 201 {"success":true,"endpoint":{…},"secret":"whsec_…"}

families is a non-empty subset of run, interaction, artifact, publication. An installation may have at most 5 endpoints. Registration requires organization.applications.manage, and delivery additionally requires the installation to hold wamp.cloud.sessions:read — an endpoint registered without it stays silent.

Delivery, and what you must verify:

Property Value
Secret format whsec_ plus 24 random bytes, base64url
Request POST over HTTPS only, Content-Type: application/json, 10-second timeout
Signature header Wamp-Signature: t=<unix seconds>,v1=<hex>
Signature value HMAC-SHA256(secret, "<t>.<raw request body>"), hex-encoded
Redirects Never followed. A 3xx counts as a failed attempt
Retries Up to 6 attempts, backing off 1, 2, 4, 8, 16, 32 minutes and capped at 6 hours. Delivery is deliberately at-least-once, so make your handler idempotent
Revocation DELETE /api/apps/:slug/installations/:installationId/webhooks/:endpointId

Verify over the raw body, before any JSON parsing or re-serialization:

t, v1 = parse("Wamp-Signature") # "t=…,v1=…"
expected = hex(hmac_sha256(secret, f"{t}.{raw_body}"))
accept if constant_time_equals(expected, v1) and abs(now - t) < your_tolerance

The scheme is Stripe’s, but the header is Wamp-Signature — not Stripe-Signature, not X-Wamp-Signature. WAMP does not state a receiver-side timestamp tolerance, so choose your own; a few minutes is conventional and leaves room for the retry schedule.

Do not confuse this with X-Hub-Signature-256. That header belongs to GitHub webhooks arriving at WAMP and is not part of any contract you implement.

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
Organization session token (token_use: organization_session) Server-minted only, for internal tenant-scoped work
Installation proxy token (sub: app-installation-proxy:…) Mintable only through the private internal delegation plane. Third parties use the installation access token above
Runner token (aud: wamp-runner) A 60-second sandbox-fleet credential, verified against a different JWKS than the platform one
Workload assertions to /internal/v1/* The private trust plane between first-party resource servers and the Account process

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
hosted_accounts_unavailable 503 Hosted accounts are not available on this deployment

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, hosted-account sign-up and sign-in
Token issuance 60 60 per minute POST /auth/refresh, POST /auth/app-installation-token, POST /auth/api-keys, app key, OAuth client, webhook, end-user and hosted-account creation, installation intents

A rejection is 429 with a Retry-After header and {"success": false, "error": "Too many requests. Please try again later.", "retryAfter": <seconds>}.

Two read-only endpoints report metered AI usage for your app, both requiring organization.applications.manage:

Terminal window
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 when your app sends end_user_id on AI requests, which is available to app principals — see Hosted end-user accounts.