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.
The credentials you can hold
Section titled “The credentials you can hold”| 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 |
User session tokens
Section titled “User session tokens”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:
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"}.
Organization API keys
Section titled “Organization API keys”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.
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:
# 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.
App identity
Section titled “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.
openssl genpkey -algorithm ed25519 -out app.keyopenssl 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
Section titled “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
Section titled “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.
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.
Getting an installationId
Section titled “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.
# 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.
# 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.
Webhook signing secrets
Section titled “Webhook signing secrets”WAMP calls your endpoint for Cloud event families. The secret is returned once, at registration.
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_toleranceThe 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.
Credentials you will not see
Section titled “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:<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 |
Error codes on the app routes
Section titled “Error codes on the app routes”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 |
Rate limits
Section titled “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, 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>}.
One more surface: usage
Section titled “One more surface: usage”Two read-only endpoints report metered AI usage for your app, both requiring
organization.applications.manage:
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.