# EvoMap Developer Docs -- Complete Documentation (en)
> 31 documents. Generated on the fly from https://evomap.ai/dev/docs
> For structured access, use ?format=json
---
## 01-introduction
# Introduction
The EvoMap developer platform lets third-party apps and AI agents read the
catalog and create and publish recipes on a user's behalf — over standard
**OAuth 2.0 + PKCE**.
EvoMap is a value pool of **genes** (ranked public assets) and **recipes**
exposed through a scoped, OAuth-secured API; your integration acts only within
the scopes a user explicitly grants, and every grant is revocable.
## What you can build
- **User-facing apps** that read the public catalog and, with consent, create and
publish recipes into the value pool on the user's behalf.
- **AI agents / MCP connectors** that self-register a read-only client and call
the API autonomously.
- **Organization integrations** where agents and services act under a shared org
identity and wallet.
## How it fits together
| Layer | What it is |
| --- | --- |
| **Auth** | OAuth 2.0 authorization-code + [PKCE](./10-oauth2-pkce.md); optional [OpenID Connect](./12-oidc.md) for sign-in. |
| **Scopes** | Fine-grained, user-approved permissions — read catalog, write drafts, publish. See [Scopes](./11-scopes.md). |
| **Data API** | Read recipes / genes / the reuse graph; create and publish recipes. Assets themselves are read-only here. See [API overview](./40-api-overview.md). |
| **Webhooks** | Server-push notifications for recipe events. See [Webhooks](./30-webhooks.md). |
| **Orgs** | Shared billing, roles, agents, and enterprise controls. See [Orgs overview](./50-orgs-overview.md). |
## Ways to connect
- **User-facing OAuth apps** — register in the [developer portal](/dev/portal),
run the consent flow, and call the API with the user's access token.
- **Machine agents** — self-register a public, read-only client with
[dynamic client registration](./13-dcr.md) (RFC 7591), no portal round-trip.
- **Org-enrolled agents** — an org admin mints an enrollment token the agent
redeems to act under the org. See [Org agents & tokens](./51-org-agents-tokens.md).
- **Agent nodes** — publish Gene / Capsule assets over the A2A protocol with a
`node_secret`; see the [agent onboarding page](/onboarding/agent). Assets are
read-only over OAuth.
## Discovery
Everything is discoverable, so compliant clients never hard-code endpoints:
- `GET /.well-known/oauth-authorization-server` — OAuth authorization-server
metadata (RFC 8414): authorize, token, revoke, introspect, and registration
endpoints.
- `GET /openapi.json` — the full OpenAPI 3.1 spec for the data API. The
[API overview](./40-api-overview.md) renders its endpoint table live from this
file, so the docs never drift from the deployed surface.
## Test vs. production
Build against [test mode](./03-test-mode.md) first — an isolated, ephemeral
sandbox where the full `register → token → publish → read` loop runs without
touching the real value pool. Swap to a live credential when your flow works end
to end.
## Start here
- **[Quickstart](./02-quickstart.md)** — register an app, run consent, make your
first API call.
- **[OAuth 2.0 + PKCE](./10-oauth2-pkce.md)** — the full authentication flow.
- **[API overview](./40-api-overview.md)** — the complete endpoint surface.
- **[Minimal examples](./64-minimal-examples.md)** — tiny Node, Python, webhook, and generated-client skeletons.
- Questions? Join the
[community discussions](https://github.com/EvoMap/developers/discussions).
---
## 02-quickstart
# Quickstart
This is the **30-minute path** from zero to your first EvoMap API call. You will
register an OAuth app, run Authorization Code + PKCE, exchange a token, read the
recipe catalog, try a sandbox publish, and know where to debug failures.
> Never paste `client_secret`, `access_token`, `refresh_token`, or webhook
> signing secrets into chat, tickets, screenshots, or logs. `client_id` is public
> and safe to show.
## What you will build
A tiny local web app that:
1. Generates a PKCE verifier/challenge.
2. Sends the user to EvoMap consent.
3. Exchanges the returned `code` for tokens.
4. Calls `GET /developer/oauth/recipes`.
5. Optionally publishes a recipe in **test mode**.
## Prerequisites
- An EvoMap account.
- A local callback URL, for example `http://localhost:3000/callback`.
- Node 20+ or Python 3.10+ for the sample client.
- `recipe:publish` is self-serve — add it to the app when you register it. Use a
**test-mode** client first so publish experiments never touch the real value
pool.
## 1. Open the developer platform
Start here:
- Developer platform landing: [/dev](/dev)
- Developer portal: [/dev/portal](/dev/portal)
- API docs: [/dev/docs](/dev/docs)
- OpenAPI: [/openapi.json](/openapi.json)
In the portal, create an OAuth app.
Recommended first app settings:
| Field | Value |
| --- | --- |
| Name | `Local Quickstart` |
| Redirect URI | `http://localhost:3000/callback` |
| Scopes | `recipe:read` first; add `recipe:write` / `recipe:publish` when you need them — all three are self-serve |
| Mode | Tick **Test mode (sandbox)** for publish experiments — it sets `test_mode: true` |
The portal returns:
- `client_id` — public identifier, safe to display.
- `client_secret` — shown once for confidential clients; store it in a local
secret manager or `.env`, never in source control.
Public / PKCE-only clients can run consent and call APIs, but **token
introspection is confidential-only**. See [OAuth 2.0 + PKCE](./10-oauth2-pkce.md)
and [Scopes](./11-scopes.md).
## 2. Generate PKCE values
Use S256 only. Keep the verifier server-side or in a secure local session until
callback.
```javascript
import crypto from "node:crypto";
export function makePkce() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
```
## 3. Send the user to consent
Build an authorize URL and redirect the browser:
```text
https://evomap.ai/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
&scope=recipe%3Aread
&code_challenge=BASE64URL_SHA256_VERIFIER
&code_challenge_method=S256
&state=RANDOM_CSRF_VALUE
```
Rules:
- `redirect_uri` must exactly match one registered on the app.
- `state` must be checked on callback.
- `code_challenge_method=plain` is rejected; EvoMap requires `S256`.
- Consent is per user and scope; users can revoke grants later.
## 4. Exchange `code` for tokens
After consent, EvoMap redirects to your callback with `?code=...&state=...`.
Verify `state`, then exchange the code.
```bash
curl -X POST https://evomap.ai/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d redirect_uri="http://localhost:3000/callback" \
-d code_verifier="$VERIFIER"
```
A successful response includes an `access_token`, a `refresh_token`, the granted
`scope`, and expiry information. Store refresh tokens securely; rotate or revoke
on logout.
## 5. Call your first API
```bash
curl https://evomap.ai/developer/oauth/recipes \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
Minimal JavaScript:
```javascript
const res = await fetch("https://evomap.ai/developer/oauth/recipes?limit=5", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { recipes } = await res.json();
console.log(recipes);
```
Minimal Python:
```python
import requests
r = requests.get(
"https://evomap.ai/developer/oauth/recipes",
params={"limit": 5},
headers={"Authorization": f"Bearer {access_token}"},
timeout=20,
)
r.raise_for_status()
print(r.json()["recipes"])
```
## 6. Try a sandbox publish
Use a **test-mode** client before live publish. A test publish runs the same
shape validation and moderation/originality path, but returns an ephemeral
`livemode: false` recipe and does not touch the real value pool, catalog,
ranking, quota, or webhooks.
```bash
curl -X POST https://evomap.ai/developer/oauth/recipe/publish \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-$(date +%s)" \
--data @recipe.json
```
Your `recipe.json` needs a `title` and **at least one step**. An empty `steps`
list is rejected with `at_least_one_step_required` before any other gate runs:
```json
{
"title": "Summarize support tickets",
"description": "Cluster tickets and draft a weekly summary.",
"steps": [
{ "asset_id": "gene_abc", "asset_type": "Gene", "position": 0 },
{ "asset_id": "capsule_xyz", "asset_type": "Capsule", "position": 1 }
]
}
```
Every step needs a non-empty `asset_id`. `asset_type` is optional and defaults
to `Gene` when omitted, but a step that sends anything other than `Gene` or
`Capsule` is **dropped silently** — so a body that looks populated can still
fail with `at_least_one_step_required`. In test mode asset ids are
shape-validated only, so the placeholders above are accepted; a live publish
resolves them against real promoted assets.
The full field list is in [API overview](./40-api-overview.md), and the
[API Explorer](./41-api-explorer.md) shows `RecipeInput` against the deployed
spec.
## 7. Add a webhook ping
Register an HTTPS webhook in the portal, subscribe to recipe events, then send a
`ping` from the portal. Verify the signature before trusting any payload.
```javascript
import crypto from "node:crypto";
export function verifyEvoMapWebhook({ rawBody, header, secret, toleranceSec = 300 }) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const actual = Buffer.from(signature || "", "hex");
const wanted = Buffer.from(expected, "hex");
return actual.length === wanted.length && crypto.timingSafeEqual(actual, wanted);
}
```
See [Webhook security](./32-webhook-security.md) and [Delivery & retries](./33-webhook-delivery.md).
## 8. Debug common failures
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `400 invalid_request` on authorize | missing PKCE, wrong redirect URI, or unsupported response type | Use `response_type=code`, registered redirect URI, and S256 PKCE. |
| `401 invalid_client` on token | wrong `client_secret`, unknown app, unapproved client, or public client hitting a confidential-only endpoint | Check app status and secret rotation. Do not call introspection from public clients. |
| `401 invalid_token` on API | missing/expired/revoked Bearer token | Refresh, reauthorize, or revoke local stale state. |
| `403 insufficient_scope` | token lacks the endpoint scope | Request the scope in the portal and send the user through consent again. |
| `429 quota_exceeded` | publish/quota/rate limit exceeded | Read the response body and retry after the indicated restore time. |
| `422 idempotency_key_reuse` | idempotency key reused with a different body | Generate a new `Idempotency-Key` for a distinct operation. |
| `422 content_rejected` | moderation/originality/shape validation failed | Fix content and retry with a new idempotency key. |
## 9. Production checklist
Before switching a live integration on:
- [ ] Run the full flow in test mode.
- [ ] Store secrets outside source control and logs.
- [ ] Use PKCE S256 and verify `state`.
- [ ] Request the smallest scopes possible.
- [ ] Implement refresh-token failure handling: stop retry loops and force
re-login on `invalid_grant` / reuse detection.
- [ ] Use `Idempotency-Key` on publish/write calls.
- [ ] Verify webhook signatures on the raw body.
- [ ] Monitor usage, calls, webhook deliveries, and quota errors in the portal.
## More examples
See [Minimal examples](./64-minimal-examples.md) for copy-pasteable Node,
Python, webhook, and generated-client skeletons.
---
## 03-test-mode
# Test mode
Test mode gives you an isolated, ephemeral **sandbox** to build and verify an
integration before it touches production data. Register a **test client** and the
full `register → token → publish → read` loop runs without persisting anything
to the real value pool.
## Test credentials
Two ways to register a **test client** — tick **Test mode (sandbox)** on the
create form in the [developer portal](/dev/portal), or send `test_mode: true` to
`POST /developer/clients` (see [Registering apps](./20-registering-apps.md)).
Either way you get a **test credential**:
- Its `client_id` is prefixed `evm_client_test_…` (live clients are
`evm_client_live_…`), and it's visually flagged in the portal.
- **Mode is welded to the credential** — there is no per-request toggle. To
switch between test and live, swap the key.
- A test client is **self-serve even for review-tier scopes** such as
`account:read` and `a2a` — the hub skips its approval check for `test_mode`, so
you can exercise those flows in the sandbox without a scope request.
## What the sandbox does
With a test token, the whole flow runs against an isolated sandbox:
- **Publishes persist nothing** to the real value pool, catalog, ranking,
originality ledger, quota, or webhooks.
- The **real (read-only) moderation and originality checks still run**, so you
get realistic verdicts — a create/publish returns a synthesized
`recipe_test_…` recipe with an `originality` verdict.
- Sandbox recipes are **readable back only** via `GET /developer/oauth/recipes`
with that same test token, and only for a limited window (**TTL ~24h**).
- `genes` and `reuse` return **empty** in test mode.
- Step assets are **shape-validated only** — placeholder gene ids are accepted.
## Telling test from live: `livemode`
Every test response carries `livemode: false`. Branch on that value — and only
on that value:
```js
const isSandbox = body.livemode === false; // the only reliable test
const isLive = !isSandbox; // absent on a read, true on a webhook
```
The field is **asymmetric**, and the two surfaces behave differently:
- **Catalog reads** (`/developer/oauth/recipes`, `/genes`, `/reuse`) carry
`livemode: false` on a test token and **omit the key entirely** on a live one.
It is never `true` here, so a `=== true` check never fires in production.
- **Webhook envelopes** always carry the field, and it is `true` for live
events. A test-mode publish fires no webhooks at all, so any event you
actually receive is a live one.
```json
{ "recipes": [ … ], "pagination": { "limit": 20 }, "livemode": false }
```
Treat a missing `livemode` as live. Then a sandbox result can never flow into
production state, whichever surface it came from.
## Sandbox host
The platform also exposes a test/staging origin, `https://dev.evomap.ai`,
alongside production `https://evomap.ai` (both are listed as servers in
`/openapi.json`). What makes a call test-mode is the **credential**, not the
host — a `evm_client_test_…` token is sandboxed wherever you send it.
## Promote to production
Once your flow works end to end against the sandbox, register (or switch to) a
**live** client and use its `evm_client_live_…` credential. Publishing stays
self-serve on a live client; review-tier scopes follow the normal request path —
see [Scopes](./11-scopes.md).
## Related
- [Registering apps](./20-registering-apps.md) — create a `test_mode` client
- [Quickstart](./02-quickstart.md) — the end-to-end flow to run in the sandbox
- [API overview](./40-api-overview.md) — the endpoints and the `livemode` flag
---
## 04-onboarding-tour
# End-to-end tour
The other getting-started pages each cover one hop. This page is the whole
chain, in order, so you can see where your integration sits before you write
code — and so the two identities and three credentials involved never get
confused with one another.
Every claim here was checked against `https://evomap.ai`.
## Three credentials, three separate paths
Most failed integrations are a credential problem, not a code problem. Three
different credentials exist, and they do not overlap at all:
| Credential | Held by | Where it comes from | Unlocks |
| --- | --- | --- | --- |
| `evomap_sid` | you, the developer | your browser session after signing in | `/developer/*`, except `/developer/oauth/*` |
| `access_token` | your app, acting for one user | exchanging a `code` after consent | `/developer/oauth/*` |
| `node_secret` | one agent node | `POST /a2a/hello`, returned once | `/a2a/publish`, `/a2a/validate`, `/a2a/fetch` |
```mermaid
flowchart LR
S["evomap_sid
developer session"] -->|Cookie header| A["/developer/clients
app lifecycle"]
T["access_token
app + one user"] -->|Bearer header| B["/developer/oauth/*
read catalog, write recipes"]
N["node_secret
one agent node"] -->|Bearer header| C["/a2a/publish
Gene / Capsule assets"]
T -.->|"no gene:write scope exists"| C
linkStyle 3 stroke-dasharray:5
```
An `access_token` cannot reach asset publishing no matter which scopes you
request — there is no `gene:write` scope. Assets are published by agent nodes
instead. In the other direction, a `node_secret` cannot read
`/developer/oauth/*`; it is rejected with `auth_scope_mismatch`.
## Two identities
Steps 1 and 7 are **you**, the developer, registering an app. Step 2 is the
**end user**, the resource owner, deciding whether that app may act for them.
They are often the same person while you are building, and the code still has to
keep them apart: the developer session can never stand in for a user's consent.
## The eight steps
| # | Step | Credential | Detail |
| --- | --- | --- | --- |
| 1 | Register a test app | `evomap_sid` | [Registering apps](./20-registering-apps.md) |
| 2 | User signs in and consents | user session | [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) |
| 3 | Exchange the code for tokens | — | [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) |
| 4 | Read the catalog | `access_token` | [API overview](./40-api-overview.md) |
| 5 | Write and publish a recipe | `access_token` | [Quickstart](./02-quickstart.md) |
| 6 | Publish a Gene / Capsule asset | `node_secret` | [API overview](./40-api-overview.md) |
| 7 | Go live | `evomap_sid` | [Test mode](./03-test-mode.md) |
| 8 | Disconnect and revoke | both | [Connected apps](./43-connected-apps.md) |
Steps 1 to 5 all run inside the sandbox. Step 6 has no sandbox at all.
## 1. Register a test app
Tick **Test mode (sandbox)** in the portal, or send `test_mode: true` to
`POST /developer/clients`. Read, draft and publish scopes are self-serve and the
app is `approved` on the spot. A test app is self-serve even for the review-tier
scopes, which is the main reason to start here.
Expect a `client_id` prefixed `evm_client_test_`, and a `client_secret` that is
shown exactly once. Branch on `2xx` rather than an exact status code.
## 2. The user signs in and consents
Send the user to `GET /oauth/authorize` with a PKCE `code_challenge`. If they
are not signed in, the consent screen sends them to sign in first and brings
them back with the original parameters — that detour is the normal first step of
the flow, not an error.
This endpoint is a browser page. Calling it with `curl` always returns `200`
with HTML, because the parameters are validated by the request the page itself
makes. Do not assert a `400` against this URL.
## 3. Exchange the code for tokens
First, on the callback from step 2, check that `state` came back unchanged and
stop if it did not — `state` belongs to the authorize round trip and is not part
of the token response. Then `POST /oauth/token` with the `code` and the
`code_verifier` you kept server-side.
The response carries `access_token`, `refresh_token`, `scope` and `expires_in`.
Note the retry semantics in
[OAuth 2.0 + PKCE](./10-oauth2-pkce.md): inside a two-minute window a repeated
exchange returns the *same* tokens rather than failing, so two successes are one
grant.
## 4. Read the catalog
Three endpoints, three scopes: `/developer/oauth/recipes` (`recipe:read`),
`/developer/oauth/genes` (`gene:read`) and `/developer/oauth/reuse`
(`reuse:query`).
On a test token, `genes` and `reuse` return **empty by design** — the sandbox
answers before it reaches the live catalog. So this step proves the response
shape, not your query logic. Verify real data in step 7.
## 5. Write and publish a recipe
Recipes are the one thing an OAuth token can write. `POST /developer/oauth/recipe`
creates a draft, and `POST /developer/oauth/recipe/{id}/publish` promotes it;
both take an `Idempotency-Key`.
In the sandbox this is genuinely free of consequence — nothing reaches the value
pool, catalog, ranking, quota or live webhooks — while the real moderation and
originality checks still run, so the verdict matches production.
## 6. Publish a Gene or Capsule asset
This branch is not OAuth. Register a node with `POST /a2a/hello`, then
authenticate with the `node_secret` it returns. `POST /a2a/validate` takes the
same envelope as `POST /a2a/publish` and only validates, which makes it the one
rehearsal available here.
There is no sandbox for `POST /a2a/publish`: it goes through admission control
into the real catalog. Two traps worth knowing before you start:
- The `hello` reply is a GEP-A2A envelope. `your_node_id` and `node_secret` live
under `payload`, not at the top level.
- A refused registration is also HTTP `200`, with the reason in
`payload.status`. Check that field before the status code.
## 7. Go live
There is no promotion step. Mode is welded to the credential, so going live
means registering a **second** app without `test_mode` and sending the user
through consent again. Expect `evm_client_live_`, and real data where the
sandbox returned empty.
The two sides are isolated: a live token cannot see sandbox recipes, and a test
token cannot see live ones.
## 8. Disconnect and revoke
A user disconnects with `POST /oauth/consents/{clientId}/revoke`, which kills
that app's tokens immediately. As the developer you can rotate a secret with
`POST /developer/clients/{id}/rotate-secret`, or disable the whole app with
`POST /developer/clients/{id}/revoke`.
## What has a sandbox and what does not
| Step | Sandbox | Real side effect |
| --- | --- | --- |
| 1 Register | yes | one test app on your account, revocable |
| 2 Consent | yes | one consent record, revocable by the user |
| 3 Token | yes | none |
| 4 Read | partial | none, but `genes` and `reuse` are always empty |
| 5 Recipe | yes | none; moderation runs, results are not recorded |
| 6 `hello` | **no** | a real node |
| 6 `validate` | effectively yes | validates only, stores nothing |
| 6 `publish` | **no** | enters the real catalog |
| 7 Live app | **no** | recipes enter the real value pool |
| 8 Revoke | yes | tokens die immediately, not reversible |
## Related
- [Quickstart](./02-quickstart.md) — the same chain with runnable code
- [Test mode](./03-test-mode.md) — what the sandbox does and does not cover
- [Scopes](./11-scopes.md) — which scopes are self-serve
- [Error codes](./44-error-codes.md) — every rejection above, with fixes
---
## 10-oauth2-pkce
# OAuth 2.0 + PKCE
EvoMap implements the OAuth 2.0 authorization-code flow with **mandatory PKCE
(S256)**, plus refresh, revocation, and introspection. Every third-party
integration — user-facing apps and AI agents alike — authenticates this way.
PKCE is required for **all** clients, including confidential ones; a missing or
`plain` `code_challenge_method` is rejected with `400 invalid_request`.
Endpoints are discoverable at
`/.well-known/oauth-authorization-server` (RFC 8414), so a compliant client can
resolve the authorization, token, revocation, introspection, and registration
endpoints without hard-coding them.
## The flow at a glance
1. **PKCE** — generate a random `code_verifier` and derive
`code_challenge = BASE64URL(SHA256(verifier))`.
2. **Authorize** — send the user to `GET /oauth/authorize` with the challenge.
They review the requested scopes and approve.
3. **Callback** — EvoMap redirects back to your `redirect_uri` with a one-time
`code` (and your `state`).
4. **Token** — exchange the `code` (plus the `code_verifier`) at
`POST /oauth/token` for an `access_token` and `refresh_token`.
5. **Call** — send `Authorization: Bearer ` to the API.
## 1. Generate the PKCE pair
The `code_verifier` is a high-entropy random string; the `code_challenge` is its
S256 hash, base64url-encoded without padding. Keep the verifier for step 3 —
never send it in step 2.
```javascript
import { randomBytes, createHash } from "node:crypto";
const b64url = (buf) =>
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const code_verifier = b64url(randomBytes(32));
const code_challenge = b64url(createHash("sha256").update(code_verifier).digest());
```
```python
import os, hashlib, base64
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
code_verifier = b64url(os.urandom(32))
code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())
```
## 2. Send the user to the consent screen
Redirect the browser to `/oauth/authorize`. The user must have a logged-in
EvoMap session; they see every requested scope and approve or deny. Always send
a random `state` and verify it on the callback to defend against CSRF.
```
https://evomap.ai/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=recipe:read recipe:publish
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
&state=RANDOM
```
If the user has already granted the requested scopes to your app, consent is
skipped and EvoMap redirects straight back with a fresh `code`.
## 3. Exchange the code for tokens
After approval, EvoMap redirects to your `redirect_uri` with `?code=…&state=…`.
POST the code together with the `code_verifier` (and, for confidential clients,
the `client_secret`) to `/oauth/token`.
```bash
curl -X POST https://evomap.ai/oauth/token \
-d grant_type=authorization_code \
-d code=$CODE \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d redirect_uri=https://yourapp.com/callback \
-d code_verifier=$VERIFIER
```
A successful response carries the tokens and their scope. `id_token` is present
only when the grant included the `openid` scope — see
[OpenID Connect](./12-oidc.md).
```json
{
"access_token": "evm_at_…",
"refresh_token": "evm_rt_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "recipe:read recipe:publish"
}
```
Public clients (SPAs, native apps, most agents) omit `client_secret` — PKCE is
what proves the exchange came from the same client that started the flow.
## 4. Refresh the access token
Access tokens are short-lived (`expires_in` seconds). Use the refresh token to
mint a new one. **Refresh tokens rotate on use**: every refresh returns a new
`refresh_token` and invalidates the old one, so always persist the newest value.
```bash
curl -X POST https://evomap.ai/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
```
## Retrying a token request safely
`POST /oauth/token` has a **2-minute idempotent retry window**, which matters
the first time you lose a token response to a timeout.
Inside that window, re-sending the same authorization code returns **HTTP 200
with byte-identical tokens** — the same grant recovered, not a second one. The
same holds for a rotated refresh token: replaying the spent value hands back its
one successor instead of forking the chain. After the window closes, or once the
tokens are revoked, both answer `400 invalid_grant`.
So a dropped response is safe to retry, and two `200`s are **one** grant. Never
read a second success as a second independent session.
Two things worth knowing about this:
- It deviates from RFC 6749 §4.1.2, which says a replayed code MUST be denied. A
conformance test written to the letter of the spec will fail here.
- It is not a replay hole. PKCE — and, for confidential clients, the
`client_secret` — are both verified *before* the retry branch, so anyone able
to replay already holds everything the first exchange needed, and gets the
same tokens back rather than new ones.
## Revoke a token (RFC 7009)
Revoke an access or refresh token when a user disconnects or you rotate
credentials. Per RFC 7009 the endpoint always returns `200`, even for an unknown
token.
```bash
curl -X POST https://evomap.ai/oauth/revoke \
-d token=$TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
```
## Introspect a token (RFC 7662)
`POST /oauth/introspect` reports whether a token is active and what it carries
(`client_id`, `username`, `scope`, `exp`). Introspection is gated by the
`OAUTH_INTROSPECT_ENABLED` server flag; if it is disabled the endpoint responds
as if the token were inactive.
```bash
curl -X POST https://evomap.ai/oauth/introspect \
-d token=$ACCESS_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
```
```json
{ "active": true, "client_id": "…", "username": "…", "scope": "recipe:read", "exp": 1718000000 }
```
An inactive, expired, or revoked token returns simply `{ "active": false }`.
## Related
- [Quickstart](./02-quickstart.md) — the end-to-end walkthrough with API calls
- [Scopes](./11-scopes.md) — what each scope grants and how to request more
- [OpenID Connect](./12-oidc.md) — add sign-in with `openid` and an ID token
- [Dynamic client registration](./13-dcr.md) — register read-only clients over RFC 7591
- [API overview](./40-api-overview.md) — the full endpoint surface
---
## 11-scopes
# Scopes
Access tokens are scoped to exactly what the user granted. Request only the
scopes your app needs — users see every scope on the consent screen, and
narrower requests convert better.
## Scope vocabulary
The full vocabulary is rendered live below this article, straight from the
platform's permission catalog: name, permission code, what it grants, its risk
grading, and how it is obtained. It cannot drift from what the developer
console and the consent screen show, because all three read the same table.
## Access tiers
- **Self-service** — identity, catalog reads, drafting (`recipe:write`) and
publishing (`recipe:publish`); any app may declare these, and they are granted
immediately on user consent.
- **On request** — the account read (`account:read`), the agent surface (`a2a`)
and recipe expression (`recipe:express`) are reviewed before your app may
request them, because they act on a user's account, nodes and running
organisms. A test-mode client may declare them without review.
- **Team sign-off** — high-risk scopes such as `node:manage` are never
self-service and are dropped from every registration.
## Requesting elevation
To request an on-request scope, open your app in the
[developer portal](/dev/portal) and submit a scope-elevation request describing
the use case; a request for any other scope is rejected with
`invalid_scope_request`. Until it is approved, authorize calls that include the
scope are rejected with `invalid_scope`.
## OpenID Connect scopes
`openid`, `profile`, and `email` are handled separately — see
[OpenID Connect](./12-oidc.md).
## Related
- [OAuth 2.0 + PKCE](./10-oauth2-pkce.md)
- [API overview](./40-api-overview.md)
---
## 12-oidc
# OpenID Connect
On top of OAuth 2.0, EvoMap exposes OpenID Connect (OIDC) for **identity** — so
your app can offer "Sign in with EvoMap" instead of just calling the API on a
user's behalf. Request the `openid` scope and the token response includes a
signed **ID token** (an RS256 JWT) describing who the user is.
Use OIDC when you need to *authenticate* a user (establish a session in your
app). Use plain OAuth scopes when you only need to *authorize* API access. The
two compose: request `openid` alongside data scopes to do both in one consent.
## Scopes
| Scope | Adds to the ID token / UserInfo |
| --- | --- |
| `openid` | Required. Issues a signed `id_token`; enables `/oauth/userinfo`. |
| `profile` | `name`, `preferred_username` claims. |
| `email` | `email` claim. |
## 1. Request `openid` on the authorize call
Add `openid` (and optionally `profile`, `email`) to the `scope` parameter of the
standard authorization-code + PKCE flow — see
[OAuth 2.0 + PKCE](./10-oauth2-pkce.md) for the full mechanics.
```
https://evomap.ai/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid profile email
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
&state=RANDOM
```
## 2. Read the ID token from the token response
Because the grant included `openid`, the `POST /oauth/token` response carries an
`id_token` in addition to the access and refresh tokens:
```json
{
"access_token": "evm_at_…",
"refresh_token": "evm_rt_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…"
}
```
The `id_token` is a signed **RS256 JWT**. Verify its signature against the JWKS
(below) and validate the `iss`, `aud` (your `client_id`), and `exp` claims
before trusting it.
## 3. Fetch profile claims from UserInfo
`GET /oauth/userinfo` returns the standard OIDC claims for the bearer access
token. It requires the `openid` scope; `name`/`preferred_username` need
`profile`, and `email` needs `email`.
```bash
curl https://evomap.ai/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
```json
{
"sub": "user_…",
"name": "Ada Lovelace",
"preferred_username": "ada",
"email": "ada@example.com"
}
```
`sub` is the stable, opaque user identifier — key your account records on it,
not on `email` (which can change). Calling UserInfo without `openid` returns
`403 insufficient_scope`; with no or an invalid token, `401 invalid_token`.
## Discovery & signature verification
Everything a compliant OIDC client needs is discoverable — don't hard-code these
URLs, read them from the discovery document.
| Endpoint | Purpose |
| --- | --- |
| `GET /.well-known/openid-configuration` | OIDC discovery — `jwks_uri`, `userinfo_endpoint`, `id_token_signing_alg_values_supported` (RS256), `claims_supported` |
| `GET /.well-known/jwks.json` | JSON Web Key Set — the public RSA key(s) that verify `id_token` signatures |
Most OIDC libraries (e.g. `openid-client`, `jose`, `pyjwt` + `PyJWKClient`)
take the discovery URL, fetch the JWKS automatically, and verify the `id_token`
for you.
## Related
- [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) — the underlying authorization flow
- [Scopes](./11-scopes.md) — the full scope vocabulary and access tiers
- [Connected apps](./43-connected-apps.md) — how users manage what they've signed into
---
## 13-dcr
# Dynamic client registration
Register OAuth clients **programmatically** with RFC 7591 Dynamic Client
Registration (DCR) instead of filling in the [developer portal](/dev/portal) by
hand. This is how MCP servers and AI agents self-register a client *before* the
user ever reaches the consent screen.
DCR is deliberately narrow. `POST /oauth/register` only issues **public,
PKCE-only** clients limited to the OpenID Connect scopes (`openid`, `profile`,
`email`) and the **read-only** scopes `gene:read`, `recipe:read`, and
`reuse:query`. Anything more — a confidential client, or write/publish scopes —
is registered self-serve in the
[developer portal](./20-registering-apps.md) instead.
The endpoint is gated by the `OAUTH_DCR_ENABLED` server flag. When it is
disabled, the endpoint is not served and returns `404`; a `503`
`temporarily_unavailable` means the pool of dynamically registered clients is
full.
## Register a client
```bash
curl -X POST https://evomap.ai/oauth/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://yourapp.com/callback"],
"client_name": "My MCP Connector",
"scope": "recipe:read gene:read"
}'
```
Only `redirect_uris` is required. `scope` is filtered, not validated: anything
outside the DCR set — `recipe:write`, `recipe:publish`, `node:manage` — is
dropped silently, and if nothing is left the client gets the whole DCR set.
Check the `scope` in the response rather than assuming the request was honoured.
## Response
On success (`201`) you get a public client — note there is **no**
`client_secret`, because DCR clients are public and rely on PKCE:
```json
{
"client_id": "evm_client_live_…",
"client_id_issued_at": 1718000000,
"redirect_uris": ["https://yourapp.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "recipe:read gene:read",
"client_name": "My MCP Connector"
}
```
`token_endpoint_auth_method: "none"` confirms the client is public: it
authenticates the token exchange with PKCE, not a secret. From here, run the
standard [authorization-code + PKCE flow](./10-oauth2-pkce.md).
## When to use DCR vs. the portal
| | Dynamic registration | Developer portal |
| --- | --- | --- |
| Client type | Public (PKCE) only | Public or confidential |
| Scopes | OIDC + read-only (`gene:read`, `recipe:read`, `reuse:query`) | Any, incl. write/publish (self-serve); review-tier scopes on request |
| Review | None — immediate | None for self-serve scopes; per-scope review for `account:read`, `a2a`, `recipe:express` |
| Best for | MCP / agent connectors provisioning at runtime | Named integrations that publish or need a secret |
The endpoint discovery document
(`/.well-known/oauth-authorization-server`) advertises the
`registration_endpoint`, so RFC 7591-aware clients find it automatically.
## Related
- [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) — the flow a registered client then runs
- [Scopes](./11-scopes.md) — which scopes are self-service vs. on request
- [Registering apps](./20-registering-apps.md) — the portal path for full-capability apps
---
## 14-secret-rotation
# Secret rotation
Confidential clients authenticate the token exchange with a `client_secret`.
Rotate it periodically, and immediately if you suspect it leaked. Rotation
issues a **new secret**, shown to you exactly once, and records the event in the
app's rotation history.
> Public / PKCE-only clients (SPAs, native apps, most agents, and
> [dynamically registered](./13-dcr.md) clients) have **no** secret to rotate —
> PKCE is what protects them. This page applies only to confidential clients.
## Rotate the secret
From the [developer portal](/dev/portal), open the app and choose **Rotate
secret**, or call the endpoint directly (session-authenticated):
```bash
curl -X POST https://evomap.ai/developer/clients/$CLIENT_ID/rotate-secret \
-b "evomap_sid=$SESSION"
```
The response returns the new secret **once** — it is never retrievable again:
```json
{ "client_secret": "evm_secret_…" }
```
Store it in your secret manager before you navigate away. If you lose it, rotate
again to mint a fresh one.
## Roll it out without downtime
The new secret takes effect on rotation, so sequence your deployment to switch
over promptly:
1. **Rotate** to obtain the new secret.
2. **Deploy** it to every service that exchanges codes or refreshes tokens —
update your secret store and roll your instances.
3. **Verify** a token exchange succeeds with the new secret.
Because rotation is a credential change, plan it during a deploy window rather
than mid-request. Access tokens already issued keep working until they expire;
only the back-channel calls to [`/oauth/token`](./10-oauth2-pkce.md) and the
other confidential-client endpoints need the new secret.
## Rotation history
The portal shows when the secret was last rotated and how many times, and lists
the full rotation timeline. The history records **timestamps only** — no secret
material is ever stored or displayed. Use it to audit that rotations happened on
schedule and to spot an unexpected rotation.
## Good practice
- Rotate on a schedule (e.g. quarterly) and immediately after any suspected
exposure.
- Keep secrets out of source control, logs, and client-side bundles — a
confidential secret belongs only on your server.
- If a secret's confidentiality can't be guaranteed (e.g. you're shipping a
browser or mobile app), use a **public** client with PKCE instead of a
confidential one — then there's no secret to rotate at all.
## Related
- [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) — where the secret is used
- [Registering apps](./20-registering-apps.md) — the app lifecycle and where the first secret comes from
- [Dynamic client registration](./13-dcr.md) — secretless public clients
---
## 20-registering-apps
# Registering apps
An OAuth **app** (client) is how your integration identifies itself to EvoMap.
Registering one gives you a `client_id` — and, for confidential apps, a
one-time `client_secret` — to run the [OAuth 2.0 + PKCE](./10-oauth2-pkce.md)
flow. This page covers the full lifecycle: create, read, update, and revoke.
Manage apps in the [developer portal](/dev/portal), or over the
session-authenticated `/developer/clients` API shown below. Registering an app is
**self-serve**: any signed-in account can create one — confidential or public —
with read, draft and publish scopes, and it is approved on the spot. Only
review-tier scopes (`account:read`, `a2a`, `recipe:express`) are refused at
registration; request them per scope once the app exists, hold an approved
developer application (see [Connected apps](./43-connected-apps.md)), or
register a [test-mode client](./03-test-mode.md), which is self-serve even for
those. A public, read-only client needs no session at all and can
[self-register over RFC 7591](./13-dcr.md).
These endpoints authenticate with your **browser session**, not an OAuth access
token. Sign in, copy the `evomap_sid` cookie from your browser, and send it as
`-b "evomap_sid=$SESSION"`. It is a personal credential with your whole account
behind it: keep it out of shared scripts and CI, and prefer the portal for
one-off changes. Everything under `/developer/oauth/` is the opposite — those
endpoints take a Bearer access token and ignore the cookie.
## Create an app
`POST /developer/clients` with the app's name, redirect URIs, and the scopes it
will request:
```bash
curl -X POST https://evomap.ai/developer/clients \
-b "evomap_sid=$SESSION" \
-H "Content-Type: application/json" \
-d '{
"name": "Recipe Importer",
"redirect_uris": ["https://yourapp.com/callback"],
"allowed_scopes": ["recipe:read", "recipe:publish"],
"description": "Imports recipes into the value pool",
"homepage_url": "https://yourapp.com",
"is_confidential": true
}'
```
| Field | Required | Notes |
| --- | --- | --- |
| `name` | ✅ | Display name shown on the consent screen. |
| `redirect_uris` | ✅ | Exact callback URLs; the `redirect_uri` in an authorize call must match one. |
| `allowed_scopes` | ✅ | Scopes the app may request. Read, draft and publish scopes are self-serve; review-tier scopes are refused here — see [Scopes](./11-scopes.md). |
| `description` | | Shown to users on consent. |
| `homepage_url` | | Your app's homepage. |
| `is_confidential` | | `true` issues a `client_secret` (server-side apps); omit/`false` for public PKCE clients. |
| `test_mode` | | `true` registers a sandbox client (`evm_client_test_…`) — see [Test mode](./03-test-mode.md). The portal's create form exposes it as the **Test mode (sandbox)** checkbox. |
The response returns the client and, for confidential apps, the secret **exactly
once**:
```json
{
"client": {
"clientId": "evm_client_live_…",
"name": "Recipe Importer",
"status": "approved",
"isConfidential": true,
"redirectUris": ["https://yourapp.com/callback"],
"allowedScopes": ["recipe:read", "recipe:publish"]
},
"client_secret": "evm_secret_…"
}
```
Branch on `2xx`, not on an exact status code. `POST /developer/clients` answers
**`200`** on `evomap.ai`, while the Bearer-token API under `/developer/oauth/`
answers `201` — the two are served by different layers, and a client that
asserts `201` here fails against the documented host.
Store `client_secret` now — it's never shown again (rotate it if you lose it,
see [Secret rotation](./14-secret-rotation.md)). An app registered with
self-serve scopes starts `approved`; only an approved developer registering a
review-tier scope gets a `pending` app that becomes `approved` after review.
## List and read your apps
```bash
# All your apps
curl https://evomap.ai/developer/clients -b "evomap_sid=$SESSION"
# One app
curl https://evomap.ai/developer/clients/$CLIENT_ID -b "evomap_sid=$SESSION"
```
Each client reports its `status` (`pending` · `approved` · `revoked`),
`redirectUris`, `allowedScopes`, `clientSecretPrefix`, and timestamps. The full
secret is never returned by a read — only the prefix, so you can recognise which
secret is live.
## Update an app
`PATCH /developer/clients/{clientId}` edits redirect URIs, scopes, or metadata
in place. Send only the fields you're changing:
```bash
curl -X PATCH https://evomap.ai/developer/clients/$CLIENT_ID \
-b "evomap_sid=$SESSION" \
-H "Content-Type: application/json" \
-d '{ "redirect_uris": ["https://yourapp.com/callback", "https://yourapp.com/callback2"] }'
```
An in-place `PATCH` is the quick path for small edits. To ship a reviewed,
whole-app config change as a versioned snapshot instead, use
[App versioning](./21-app-versioning.md).
## Revoke an app
`POST /developer/clients/{clientId}/revoke` disables the app and **immediately
invalidates its tokens** — every access and refresh token issued to it stops
working. Use it when an integration is retired or a `client_id` is compromised.
```bash
curl -X POST https://evomap.ai/developer/clients/$CLIENT_ID/revoke \
-b "evomap_sid=$SESSION"
```
## Related
- [Test mode](./03-test-mode.md) — build against sandbox clients first
- [Secret rotation](./14-secret-rotation.md) — rotate a confidential secret safely
- [App versioning](./21-app-versioning.md) — reviewed, whole-app config changes
- [Usage & activity logs](./22-usage-logs.md) — monitor how the app is used
- [Scopes](./11-scopes.md) — what each scope grants and how to request more
---
## 21-app-versioning
# App versioning
Ship a whole-app config change as a reviewable **version** instead of editing a
live client in place. You submit a full config snapshot — name, redirect URIs,
scopes and declared webhook events — with a changelog and justification; the
live client keeps serving its current config until a moderator approves. On
approval the snapshot is applied atomically.
- Submit a new version with an updated config snapshot, a changelog, and a
justification.
- The live app keeps running its current config while the version is `pending`
review — approval is what promotes it to production.
- At most one open (`draft` / `pending`) version exists per app at a time.
- A snapshot may carry self-service and review-tier scopes (`account:read`,
`a2a`, `recipe:express`); the reviewer is what grants the review-tier ones,
at approve time, exactly as for a per-scope request. Team sign-off scopes such
as `node:manage` are dropped from the snapshot.
## Endpoints
Owner endpoints are session-authenticated (developer portal). Review endpoints
require a moderator.
| Method | Path | Notes |
| --- | --- | --- |
| POST | `/developer/clients/{clientId}/versions` | Submit a new version — `{ config, changelog, justification }`; rate-limited to 20/hour |
| GET | `/developer/clients/{clientId}/versions` | List the app's versions, newest first |
| GET | `/admin/oauth/client-versions` | Moderator: review queue · `?status=pending\|approved\|rejected\|all ?limit` |
| PATCH | `/admin/oauth/client-versions/{id}` | Moderator: `{ decision: approved\|rejected, reject_reason? }` — approve applies the snapshot |
The full request/response shapes are in the OpenAPI spec under the **App
versions** tag: [OpenAPI 3.1 (JSON)](https://evomap.ai/openapi.json) ·
[YAML](https://evomap.ai/openapi.yaml).
- See [Registering apps](./20-registering-apps.md) for the in-place edit path and
[API overview](./40-api-overview.md) for the full endpoint surface.
---
## 22-usage-logs
# Usage & activity logs
Monitor how your app is being used — aggregate usage, a timeline of notable
events, and (in the portal) recent individual API calls for debugging. All three
are owner-scoped and read from your logged-in session.
## Usage summary
`GET /developer/clients/{clientId}/usage` returns an aggregate snapshot for the
app — how much it's published, how many users authorized it, active tokens, and
when it was last active:
```bash
curl https://evomap.ai/developer/clients/$CLIENT_ID/usage \
-b "evomap_sid=$SESSION"
```
```json
{
"usage": {
"publishedArtifacts": 42,
"authorizedUsers": 128,
"activeTokens": 96,
"lastActiveAt": "2026-06-17T12:00:00Z"
}
}
```
The `usage` object is an **open map** — treat the fields above as representative
and tolerate additional keys, since the summary can gain metrics over time. Use
it for at-a-glance health (is the app live? how many users? how many active
tokens?), not for per-request accounting.
## Activity timeline
`GET /developer/clients/{clientId}/activity` returns a timeline of notable app
events — approvals, config changes, revocations, and similar — newest first:
```bash
curl https://evomap.ai/developer/clients/$CLIENT_ID/activity \
-b "evomap_sid=$SESSION"
```
```json
{
"activity": [
{ "type": "…", "at": "2026-06-17T12:00:00Z", "…": "event-specific fields" }
]
}
```
Each entry is an open object; read the fields you need. Use the activity feed to
answer "what changed on this app, and when."
## Recent API calls (owner diagnostic)
`GET /developer/clients/{clientId}/calls` returns the latest individual API
calls for an app, including method, path, HTTP status, and latency. This is a
session-authenticated owner diagnostic: use your EvoMap session cookie, not the
app's OAuth access token.
```bash
curl "https://evomap.ai/developer/clients/$CLIENT_ID/calls?limit=50" \
-b "evomap_sid=$SESSION"
```
```json
{
"calls": [
{
"at": "2026-06-17T12:00:08Z",
"method": "GET",
"path": "/developer/oauth/recipes",
"status": 200,
"ms": 42
},
{
"at": "2026-06-17T12:01:19Z",
"method": "POST",
"path": "/developer/oauth/recipes",
"status": 503,
"ms": 1200,
"error": "service_temporarily_unavailable"
}
]
}
```
The [developer portal](/dev/portal) uses the same endpoint for its
**recent-calls** view, so you can spot errors and compute a rough error rate
while debugging an integration. `limit` defaults to 50 and is capped at 200.
## Practical use
- **Health check** — poll `usage` to confirm an app is live and see its
authorized-user and active-token counts.
- **Audit** — read `activity` to see approvals, edits, and revocations over time.
- **Debug** — open the portal recent-calls view to find failing calls by HTTP
status when an integration misbehaves.
Pagination, where a list grows large, follows the platform-wide conventions in
[Consistency primitives](./42-consistency.md).
## Related
- [Registering apps](./20-registering-apps.md) — the app lifecycle these logs track
- [Consistency primitives](./42-consistency.md) — pagination and rate-limit conventions
- [Webhooks](./30-webhooks.md) — push notifications instead of polling usage
---
## 30-webhooks
# Webhooks
Register a webhook endpoint to receive **server-push** notifications when events
happen — a recipe is created, published, or taken down — instead of polling the
API. EvoMap POSTs a signed JSON envelope to your HTTPS URL for each event and
retries on failure.
Webhooks are scoped to one of your OAuth apps: you register them per client, and
they fire for events that app is involved in.
## Register an endpoint
`POST /developer/clients/{clientId}/webhooks` with the HTTPS URL and the event
types you want. The URL is **SSRF-validated** at registration — `localhost`,
private/loopback IP ranges, and cloud-metadata addresses are rejected, so the
endpoint must be a real public HTTPS URL.
```bash
curl -X POST https://evomap.ai/developer/clients/$CLIENT_ID/webhooks \
-b "evomap_sid=$SESSION" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/evomap",
"events": ["recipe.published", "recipe.takedown"]
}'
```
Subscribable event types are `recipe.created`, `recipe.published`, and
`recipe.takedown` — see the [Event catalog](./31-event-catalog.md).
## The signing secret is shown once
The `201` response includes the endpoint and its **signing secret** — returned
**only at creation** and never again:
```json
{
"id": "wh_…",
"url": "https://yourapp.com/hooks/evomap",
"events": ["recipe.published", "recipe.takedown"],
"secret": "whsec_…"
}
```
Store `secret` in your secret manager immediately — you need it to verify every
delivery (see [Webhook security](./32-webhook-security.md)). If you lose it,
delete the webhook and register a new one.
## Verify your endpoint with a ping
Before relying on it, send a test delivery. `POST
/developer/webhooks/{webhookId}/ping` delivers a `ping` event so you can confirm
your endpoint receives the POST and your signature check passes end to end.
```bash
curl -X POST https://evomap.ai/developer/webhooks/$WEBHOOK_ID/ping \
-b "evomap_sid=$SESSION"
```
## Manage webhooks
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/developer/clients/{clientId}/webhooks` | Register an endpoint (returns the secret once) |
| GET | `/developer/clients/{clientId}/webhooks` | List the app's webhooks |
| DELETE | `/developer/webhooks/{webhookId}` | Delete a webhook |
| POST | `/developer/webhooks/{webhookId}/ping` | Send a `ping` test event |
| GET | `/developer/webhooks/{webhookId}/deliveries` | Inspect recent delivery attempts |
| POST | `/developer/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver` | Re-send a past event |
Webhook management is session-authenticated (developer portal / your logged-in
session) and owner-scoped — you can only manage webhooks on your own apps.
## What to build
1. Expose a public HTTPS endpoint that accepts `POST` with a JSON body.
2. **Verify the signature** on every request before trusting it —
[Webhook security](./32-webhook-security.md).
3. **Return `2xx` fast** (under a couple of seconds) and do slow work
asynchronously — a slow or non-2xx response is treated as a failed delivery
and [retried](./33-webhook-delivery.md).
4. **Dedupe on `event.id`** — a redelivery repeats the same `evt_…` id.
## Related
- [Event catalog](./31-event-catalog.md) — event types and payloads
- [Webhook security](./32-webhook-security.md) — verify signatures, prevent replay
- [Delivery & retries](./33-webhook-delivery.md) — the retry schedule and redelivery
---
## 31-event-catalog
# Event catalog
Every webhook delivery is a signed JSON envelope with the same top-level shape,
regardless of event type. Subscribe to the types you care about when you
[register the webhook](./30-webhooks.md); EvoMap POSTs an envelope for each
matching event.
## The envelope
```json
{
"id": "evt_…",
"type": "recipe.published",
"created": "2026-06-17T12:00:00Z",
"livemode": true,
"data": { "…": "event-specific fields" }
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Unique event id (`evt_…`). **Dedupe on this** — a redelivery repeats it. |
| `type` | string | The event type (table below). |
| `created` | string | ISO-8601 timestamp of when the event occurred. |
| `livemode` | boolean | `true` for real events; `false` for events produced by a test-mode client. |
| `data` | object | Event-specific payload — the affected resource. |
`livemode` lets one endpoint safely handle both real and
[test-mode](./03-test-mode.md) traffic: branch on it so a sandbox event never
touches production state.
## Event types
| Type | Subscribable | Fires when |
| --- | --- | --- |
| `recipe.created` | ✅ | A recipe **draft** is created. |
| `recipe.published` | ✅ | A recipe enters the public value pool. |
| `recipe.takedown` | ✅ | A published recipe is removed. |
| `ping` | — | A [test delivery](./30-webhooks.md) you trigger to verify an endpoint. Not a subscribable type. |
You choose from the subscribable types (`recipe.created`, `recipe.published`,
`recipe.takedown`) in the `events` array at registration. `ping` is delivered
only when you explicitly call the ping endpoint, so you never subscribe to it —
but your handler should still accept it (it arrives signed, exactly like a real
event).
## The `data` payload
`data` carries the resource the event is about — for the `recipe.*` types, the
affected recipe. Treat `data` as an **open object**: read the fields you need and
tolerate additional ones, since the payload can gain fields over time without a
breaking change. When in doubt, use the `id`/`type` in the envelope to look the
resource up via the [API](./40-api-overview.md) rather than relying on a
specific `data` field being present.
## Handling guidance
- **Dedupe** on `event.id` — retries and manual redeliveries reuse the same id.
- **Branch on `livemode`** so test events don't mutate production data.
- **Don't assume ordering** — deliveries can arrive out of order or be retried;
design handlers to be idempotent.
## Related
- [Webhooks](./30-webhooks.md) — register endpoints and subscribe to events
- [Webhook security](./32-webhook-security.md) — verify each delivery is authentic
- [Delivery & retries](./33-webhook-delivery.md) — what happens when your endpoint fails
---
## 32-webhook-security
# Webhook security
Anyone can POST to a public URL, so **verify every delivery** before acting on
it. EvoMap signs each webhook with an HMAC keyed by the signing `secret` you
received when you [registered the endpoint](./30-webhooks.md). A request that
fails verification must be rejected.
## The signature header
Each delivery carries:
```
X-EvoMap-Webhook-Signature: t=1718000000,v1=
```
- `t` — the Unix timestamp when the signature was created.
- `v1` — HMAC-SHA256, hex-encoded, computed over the string `` `${t}.${rawBody}` ``
(the timestamp, a literal `.`, then the **raw request body**) using your
webhook `secret` as the key.
A legacy `X-EvoMap-Signature: sha256=` header (HMAC over the body
alone, no timestamp) is also sent for backward compatibility. Prefer
`X-EvoMap-Webhook-Signature` — the timestamped scheme is what lets you reject
replays.
## Verify a delivery
Compute the expected `v1` over `` `${t}.${rawBody}` `` and compare it to the
header value in **constant time**. Two rules matter:
1. Sign over the **raw body bytes**, exactly as received — verifying against a
re-serialized JSON object will fail, because key order and whitespace differ.
2. Reject a delivery whose `t` is outside your tolerance window (e.g. ±5
minutes) to guard against replayed captures.
```javascript
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {string} rawBody - the exact request body bytes
* @param {string} header - value of X-EvoMap-Webhook-Signature
* @param {string} secret - your webhook signing secret (whsec_…)
* @param {number} toleranceSec
* @returns {boolean}
*/
export function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && timingSafeEqual(a, b);
}
```
```python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
```
## Checklist
- **Read the raw body first.** Capture the body bytes before any JSON parsing or
framework middleware re-serializes them.
- **Constant-time compare** (`timingSafeEqual` / `hmac.compare_digest`) — never
`==` — to avoid timing side channels.
- **Enforce the timestamp window.** A valid signature with a stale `t` is a
replay; reject it.
- **Return `2xx` only after verifying.** On a verification failure, return `4xx`
and do nothing.
- **Keep the secret server-side.** Rotate it (delete + re-register the webhook)
if it may have leaked.
## Related
- [Webhooks](./30-webhooks.md) — registration and the one-time signing secret
- [Event catalog](./31-event-catalog.md) — the envelope you're verifying
- [Delivery & retries](./33-webhook-delivery.md) — what a rejected delivery triggers
---
## 33-webhook-delivery
# Delivery & retries
Every webhook delivery attempt is recorded so you can debug failures and
re-send events. If your endpoint is briefly down, EvoMap retries automatically;
if it was down longer, you can redeliver by hand once it recovers.
## Delivery records
`GET /developer/webhooks/{webhookId}/deliveries` lists recent attempts (owner
only). Each record is kept for about **7 days**:
```json
{
"id": "whd_…",
"event": "recipe.published",
"event_id": "evt_…",
"status": "failed",
"http_status": 500,
"attempts": 3,
"last_error": "endpoint returned 500",
"created_at": "2026-06-17T12:00:00Z",
"delivered_at": null
}
```
| Field | Meaning |
| --- | --- |
| `id` | Delivery id (`whd_…`) — pass it to the redeliver endpoint. |
| `event` / `event_id` | The event type and its `evt_…` id. |
| `status` | `delivered` or `failed`. |
| `http_status` | The HTTP status your endpoint returned (or `null` if unreachable). |
| `attempts` | How many times delivery was tried. |
| `last_error` | The most recent failure reason (`null` once delivered). |
| `created_at` / `delivered_at` | When the event was queued / successfully delivered. |
A delivery counts as successful only when your endpoint returns a **`2xx`**.
Any non-2xx response, a timeout, or a connection failure marks the attempt
failed and schedules a retry.
## Automatic retries
Failed deliveries are retried automatically with **exponential backoff** — each
retry waits progressively longer than the last, so a brief outage recovers on
its own without you doing anything. Retries stop once the delivery succeeds or
the attempts are exhausted; the final state is visible in the delivery record.
Because retries (and manual redeliveries) repeat the **same `event.id`**, your
handler must be idempotent — dedupe on that id so a re-delivered event isn't
processed twice. See [Event catalog](./31-event-catalog.md).
## Manual redelivery
After you fix an endpoint, re-send a specific past event with `POST
/developer/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver` (owner only):
```bash
curl -X POST \
https://evomap.ai/developer/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/redeliver \
-b "evomap_sid=$SESSION"
```
This delivers the originally logged event again — same `event.id`, so your
dedupe logic keeps it safe to replay.
## Design your endpoint for reliable delivery
- **Return `2xx` quickly.** Acknowledge receipt (after verifying the signature),
enqueue the work, and process asynchronously. A slow handler that ties up the
request looks like a failure and gets retried.
- **Be idempotent.** Dedupe on `event.id`; assume any event can arrive more than
once.
- **Don't depend on order.** Retries and backoff mean events can arrive out of
sequence.
- **Monitor the deliveries list** during rollout to confirm your endpoint is
returning `2xx`.
## Related
- [Webhooks](./30-webhooks.md) — registration, ping, and management
- [Event catalog](./31-event-catalog.md) — the envelope and `event.id` for dedupe
- [Webhook security](./32-webhook-security.md) — verify before you return `2xx`
---
## 40-api-overview
# API overview
Call the API with your access token as a Bearer credential. All responses are
JSON. The endpoint table below is rendered live from the OpenAPI spec — the
interactive component beneath this article reads `/openapi.json` directly, so it
never drifts from the deployed surface.
Machine-readable spec:
[OpenAPI 3.1 (JSON)](https://evomap.ai/openapi.json) ·
[YAML](https://evomap.ai/openapi.yaml) — import into Postman / Insomnia or
generate a typed client.
## Scope-gated data endpoints
| Method | Path | Scope | Notes |
| --- | --- | --- | --- |
| GET | `/developer/oauth/recipes` | `recipe:read` | Promoted recipe catalog · `?q ?limit` |
| GET | `/developer/oauth/genes` | `gene:read` | Ranked public asset catalog · `?type ?limit` |
| GET | `/developer/oauth/reuse` | `reuse:query` | Reuse / related graph · `?asset_id \| ?recipe_id` |
| POST | `/developer/oauth/recipe` | `recipe:write` | Create a recipe draft |
| POST | `/developer/oauth/recipe/publish` | `recipe:publish` | Create + publish a recipe |
## What the OAuth data API does not cover
Genes and capsules — the ranked public **assets** — are read-only here:
`gene:read` unlocks `GET /developer/oauth/genes`, nothing writes to the asset
catalog over an OAuth token, and there is no `gene:write` scope. Assets are
published by **agent nodes** over the A2A protocol instead: register a node with
`POST /a2a/hello`, then send a Gene + Capsule bundle to `POST /a2a/publish`
authenticated with the node's `node_secret`. The
[agent onboarding page](/onboarding/agent) has a copy-pasteable request, and
`GET /a2a/skill?topic=publish` documents the envelope. Recipes are the one asset
type an OAuth app can write (`recipe:write` / `recipe:publish`).
Two things about `POST /a2a/hello` that its own `?topic=hello` reference gets
wrong today. The reply is a GEP-A2A **envelope**: `your_node_id` and
`node_secret` live under `payload`, not at the top level — the `?topic=publish`
page has this right. And a **refusal also arrives as HTTP `200`**, with the
reason in `payload.status: "rejected"`; a client that checks only the status
code reads that as success and then loops on an empty secret. Check
`payload.status` before anything else.
## OAuth 2.0 protocol endpoints
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/oauth/authorize` | Start the consent flow (PKCE S256) |
| POST | `/oauth/token` | Exchange code / refresh for tokens |
| POST | `/oauth/revoke` | Revoke a token (RFC 7009) |
| POST | `/oauth/introspect` | Token introspection (RFC 7662) |
| GET | `/.well-known/oauth-authorization-server` | Endpoint discovery (RFC 8414) |
## Marketplace catalog & user installs
The public catalog is unauthenticated; the `/marketplace/me/*` views are
session-authenticated. A user "install" is the OAuth consent recorded by
`/oauth/authorize` — there is no server-side install shortcut.
| Method | Path | Auth | Notes |
| --- | --- | --- | --- |
| GET | `/marketplace/apps` | public | Published apps · `?category ?q ?limit ?cursor` |
| GET | `/marketplace/apps/{slug}` | public | One published app by slug |
| GET | `/marketplace/apps/{slug}/install-state` | public | Caller's install eligibility (works signed out) |
| GET | `/marketplace/me/installations` | session | Your installed user-audience apps |
| DELETE | `/marketplace/me/installations/{clientId}` | session | Uninstall = revoke your OAuth consent. Not served on evomap.ai — use `POST /oauth/consents/{clientId}/revoke` |
## App listing & dashboard (owner)
Session-authenticated portal endpoints for app owners.
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/developer/clients/{clientId}/listing` | Read the Marketplace listing |
| PUT | `/developer/clients/{clientId}/listing` | Create / update the listing draft |
| POST | `/developer/clients/{clientId}/listing/submit` | Submit for moderator review |
| DELETE | `/developer/clients/{clientId}/listing` | Hide / archive the listing |
| GET | `/developer/clients/{clientId}/dashboard` | Aggregate dashboard: config, listing, review state, install counts |
## Tenant app installs (org admin)
Session-authenticated org-admin endpoints (member for creating install
requests) — reference-only in the API Explorer, not callable with a bearer
token. Installs freeze the granted scopes + app version as a consent snapshot;
app drift flips `reauth_required` instead of silently widening the grant.
| Method | Path | Role | Notes |
| --- | --- | --- | --- |
| GET | `/org/{orgId}/apps` | admin | List installations · `?status` |
| POST | `/org/{orgId}/apps` | admin | Install with `client_id` in the body |
| POST | `/org/{orgId}/apps/{installationId}/disable` | admin | Revoke live tokens, keep the grant |
| POST | `/org/{orgId}/apps/{installationId}/enable` | admin | Resume token issuance |
| POST | `/org/{orgId}/apps/{installationId}/revoke` | admin | Kill tokens AND revoke the grant |
| GET | `/org/{orgId}/app-install-requests` | admin | Member request inbox · `?status` |
| POST | `/org/{orgId}/app-install-requests` | member | Propose an app install |
| POST | `/org/{orgId}/app-install-requests/{requestId}/approve` | admin | Approve into a real installation |
| POST | `/org/{orgId}/app-install-requests/{requestId}/reject` | admin | Reject with an optional note |
| GET | `/org/{orgId}/marketplace/installations` | admin | Same list, marketplace-prefixed path |
| POST | `/org/{orgId}/marketplace/apps/{clientId}/install` | admin | Install with `clientId` in the path |
| GET | `/org/{orgId}/marketplace/installations/{installationId}` | admin | Detail with drift breakdown |
| POST | `/org/{orgId}/marketplace/installations/{installationId}/reauthorize` | admin | Refresh the consent snapshot |
| DELETE | `/org/{orgId}/marketplace/installations/{installationId}` | admin | Uninstall + revoke the org grant |
## Errors
Errors use stable machine codes in a flat JSON body. OAuth protocol endpoints
follow RFC 6749-style `error` values; developer data API errors may also include
`type` and `request_id`. Rate limits and publish quota have machine-actionable
retry timing.
See [Error codes](./44-error-codes.md) for the full code table and
troubleshooting playbooks, and [Consistency primitives](./42-consistency.md) for
the unified error body, pagination, idempotency, and rate-limit headers.
## Try it live
Use the [API explorer](./41-api-explorer.md) to call any Bearer-token endpoint
from your browser.
---
## 41-api-explorer
# API explorer
Try any Bearer-token endpoint from your browser — no `curl`, no leaving the
docs. The interactive console appears **below this article**: paste an access
token, pick an endpoint, fill in parameters, and send.
## How it works
- It fetches the **live OpenAPI spec** (`/openapi.json`) and lists **every**
endpoint — the same data and publish endpoints described in the
[API overview](./40-api-overview.md), always in sync with what's deployed.
- Requests are made **same-origin** to EvoMap. Your access token stays in the
browser and is sent only to EvoMap on the call you make — no third-party proxy.
- Responses (status, selected headers, JSON body) are shown inline so you can
inspect the exact shape, including `pagination`, `livemode`, `request_id`, and
retry headers.
## What you can run, and what stays reference-only
Two rules decide it, and the console tells you which applies:
- **Runnable — every Bearer-token endpoint.** All `oauth2` operations, including
the `/developer/oauth/*` data and publish endpoints and `GET /oauth/userinfo`.
The access token you paste is exactly the credential they need.
- **Runnable — the public discovery documents.** `GET
/.well-known/oauth-authorization-server`, `GET
/.well-known/openid-configuration`, and `GET /.well-known/jwks.json` are
read-only static JSON and need no credential at all.
- **Reference only — `POST /oauth/token`, `/oauth/register`, `/oauth/introspect`,
`/oauth/revoke`.** These take or issue a `client_secret`, and `revoke`
destroys a live token. A docs page is the wrong place to paste a client secret
or to nuke the token you're currently testing with, so they are deliberately
not runnable — use the [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) flow from your
own app instead.
- **Reference only — portal and admin endpoints.** Everything under
`/developer/clients/*`, `/developer/webhooks/*`, `/oauth/authorize`, and the
rest authenticates with your **portal session cookie**, not a bearer token.
Use the [developer portal](/dev/portal) for those.
Selecting a reference-only endpoint still shows its method, path, and summary —
plus one line saying exactly why it can't be sent from here.
## Snippets and the server selector
Every request you compose also renders copyable snippets in four languages:
**curl**, **JavaScript (`fetch`)**, **Python (`requests`)**, and **Go
(`net/http`)**. Snippets read the credential from the environment
(`$ACCESS_TOKEN`, `process.env.ACCESS_TOKEN`, `os.environ["ACCESS_TOKEN"]`,
`os.Getenv("ACCESS_TOKEN")`) — your pasted token is never embedded, so a snippet
is safe to paste into a bug report. The **server selector**
(production `https://evomap.ai` or staging `https://dev.evomap.ai`) only changes
the base URL in the generated snippets; the in-browser try-it call always stays
same-origin so your token is never sent to another host.
## Get a token
You need an access token to call anything:
1. Run the [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) flow for your app to obtain an
`access_token`, or grab one your app already holds.
2. Paste it into the console's token field.
3. The token's [scopes](./11-scopes.md) determine which endpoints succeed — a
call needing a scope your token lacks returns `403 insufficient_scope`.
## Use a test token
While experimenting, prefer a **[test-mode](./03-test-mode.md)** token: publishes
run in the isolated sandbox (nothing hits the real value pool) and responses
carry `livemode: false`. Switch to a live token only when you're verifying
production behavior.
## Related
- [API overview](./40-api-overview.md) — the full endpoint table (also rendered live from the spec)
- [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) — how to obtain an access token
- [Consistency primitives](./42-consistency.md) — the pagination, headers, and error body you'll see in responses
- [Error codes](./44-error-codes.md) — stable error codes, retry guidance, and troubleshooting playbooks
---
## 42-consistency
# Consistency primitives
Cross-cutting conventions that apply across the API: pagination, idempotency,
rate limiting, and a unified error body. Learn them once and they hold for every
endpoint in the [API overview](./40-api-overview.md).
## Pagination
Every data-API list response carries a `pagination` object:
```json
{
"recipes": [ … ],
"pagination": { "limit": 20, "next_cursor": "…", "has_more": true }
}
```
| Field | Meaning |
| --- | --- |
| `limit` | The page size that was applied (`?limit`, 1–100, default 20). Always present. |
| `next_cursor` | Opaque keyset cursor — pass it back as `?cursor` for the next page. `null` on the last page. |
| `has_more` | Whether a further page exists. |
Keyset-cursor catalogs (e.g. the recipe catalog) carry all three fields. Bounded
top-N feeds — ranked genes, reuse neighbourhoods, relevance-ranked text search —
return a single page and carry **only `limit`** (`next_cursor` / `has_more` are
absent). Drive pagination off `next_cursor`, not by incrementing an offset.
## Idempotency
Creating a recipe accepts an optional **`Idempotency-Key`** header (8–255
characters) so retries are safe:
```bash
curl -X POST https://evomap.ai/developer/oauth/recipe \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: 3f9a…-a-stable-key" \
-H "Content-Type: application/json" \
-d '{ "title": "…" }'
```
- An identical retry with the **same key** replays the original `201` instead of
creating a second recipe.
- Reusing the same key with a **different body** returns `422` — the key is
bound to the first request's content.
Generate one key per logical operation (e.g. a UUID) and reuse it on retry.
## Rate limits
Data-API reads are rate-limited per access token. Every response exposes the
current window:
| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Requests allowed per window. |
| `X-RateLimit-Remaining` | Requests left in the current window. |
| `X-RateLimit-Reset` | Unix seconds when the window resets. |
When you exceed it you get `429` with a `Retry-After` header **and** a JSON body
with machine-actionable timing:
```json
{
"error": "rate_limited",
"retry_after_ms": 1200,
"next_request_at": "2026-06-17T12:00:01Z",
"bucket": "…",
"hint": "…",
"agent_instruction": "…"
}
```
Back off until `next_request_at` (or `retry_after_ms`) rather than retrying
immediately. `agent_instruction` is a plain-language directive convenient for
autonomous agents.
> **Publish quota is separate.** A `429` from a **publish** endpoint is a *quota*
> response, not a rate limit — its body describes the quota tier and (for soft
> demotions) carries an `X-Quota-Restored-At` header telling you when quota
> resets. See [API overview](./40-api-overview.md).
## Error body
Every `4xx`/`5xx` returns a flat `error` envelope; the fullest form is:
```json
{
"error": "insufficient_scope",
"error_description": "…",
"request_id": "req_…",
"type": "auth_error"
}
```
| Field | Meaning |
| --- | --- |
| `error` | Machine-readable code. OAuth-protocol endpoints use RFC 6749 codes here. |
| `error_description` | Optional human-readable detail. |
| `request_id` | Correlation id; mirrors the `X-Request-Id` response header — **quote it in support requests**. |
| `type` | Coarse class: `auth_error` · `invalid_request` · `rate_limited` · `conflict` · `not_found` · `server_error` · `service_unavailable`. |
Branch on `error` for specific handling and on `type` for coarse buckets (e.g.
"retryable vs. not"). Always log `request_id` — it's how support traces a call.
Only developer data-API errors add `type` and `request_id`. OAuth-protocol
endpoints answer RFC 6749-style (`error` plus an optional `error_description`),
a schema-validation failure answers `validation_error` with a `details` array,
and session-cookie APIs and `GET /a2a/assets` answer `unauthorized` — none of
those carry `type` or `request_id`. See [Error codes](./44-error-codes.md).
## Related
- [API overview](./40-api-overview.md) — the endpoint surface and OpenAPI links
- [API explorer](./41-api-explorer.md) — see these headers and bodies live
- [Error codes](./44-error-codes.md) — stable codes, retry guidance, and troubleshooting playbooks
- [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) — auth errors (`401`/`403`) in context
---
## 44-error-codes
# Error codes
Every API error has a stable `error` code. Branch on `error` for precise
handling, branch on `type` for coarse buckets, and always log `request_id` when
it is present so support can trace the call.
## Error envelopes
OAuth protocol endpoints follow RFC 6749-style errors:
```json
{
"error": "invalid_request",
"error_description": "code_challenge_method is required and must be S256"
}
```
Developer data API errors keep the same flat `error` field and may add a coarse
`type` plus a traceable `request_id`:
```json
{
"error": "insufficient_scope",
"scope": "recipe:publish",
"type": "auth_error",
"request_id": "req_..."
}
```
Schema validation runs before every handler. A body or form that does not match
the OpenAPI schema is answered with `validation_error` and a `details` array
naming each offending field — this envelope carries no `type`,
`error_description` or `request_id`, and the code the handler would have
returned never gets a chance: an unknown `grant_type` on `POST /oauth/token`
surfaces as `validation_error`, not `unsupported_grant_type`, and
`POST /oauth/register` without `redirect_uris` as `validation_error`, not
`invalid_request`.
```json
{
"error": "validation_error",
"details": [
{ "path": ["grant_type"], "message": "Invalid option: expected one of \"authorization_code\"|\"refresh_token\"|..." }
]
}
```
Rate-limit responses include machine-actionable retry timing:
```json
{
"error": "rate_limited",
"retry_after_ms": 1200,
"next_request_at": "2026-06-17T12:00:01Z",
"hint": "Rate limited. Wait until next_request_at before retrying, then add a small jitter (50-300ms).",
"agent_instruction": "sleep_until_next_request_at"
}
```
## Error types
| Type | HTTP | Meaning | Retry | Fix |
| --- | --- | --- | --- | --- |
| `auth_error` | 401 / 403 | Missing, invalid, expired, or under-scoped credential. | No | Refresh the token, request the missing scope, or send the user through consent again. |
| `invalid_request` | 400 / 422 | Malformed parameters, JSON body, PKCE fields, or idempotency usage. | No | Validate the request against the OpenAPI schema and fix the field called out by `error_description`. |
| `rate_limited` | 429 | A token, org, IP, or publish quota window was exceeded. | Yes | Wait until `next_request_at`, `Retry-After`, or `X-Quota-Restored-At`; add jitter before retrying. |
| `conflict` | 409 | The request conflicts with current state. | Sometimes | Retry only when the code is transient, such as an in-flight idempotency key. Otherwise resolve state first. |
| `not_found` | 404 | The resource is missing or not owned by the caller. | No | Check the id, ownership, and test/live mode. |
| `service_unavailable` | 503 | Temporary infrastructure or capacity issue. | Yes | Use exponential backoff and keep the `request_id` for support. |
| `server_error` | 500 | Unexpected server failure. | Yes | Retry with backoff; contact support with `request_id` if it persists. |
## Common codes
The Type column is the `type` field the response actually carries. OAuth-protocol
bodies (`OAuthProtocolError`) and pre-handler bodies (`validation_error`,
`unauthorized`) have none, so their rows show a dash.
| Code | HTTP | Type | Seen on | Retry | Fix |
| --- | --- | --- | --- | --- | --- |
| `invalid_request` | 400 | — (none) | OAuth-protocol endpoints (authorize, token, register), app registration | No | Fix the missing or malformed parameter named in `error_description`; the body is `error` plus `error_description` only. |
| `invalid_request` | 400 | `invalid_request` | Bearer data API | No | Fix the missing or malformed parameter/body field. |
| `validation_error` | 400 | — (none) | Any JSON / form body: OAuth token, DCR, app registration, publishing | No | Fix every field listed in `details`; the envelope has no `type` or `request_id`, and the endpoint's own code (such as `unsupported_grant_type`) only appears once the body validates. |
| `invalid_idempotency_key` | 400 | `invalid_request` | Publishing | No | Send an `Idempotency-Key` between 8 and 255 characters. |
| `invalid_client` | 401 | — (none) | OAuth token exchange | No | Check `client_id`, client secret, and whether the client is active. |
| `invalid_grant` | 400 | — (none) | OAuth token exchange | No | The code or refresh token is unknown, expired, revoked, or past the 2-minute retry window. A replay *inside* that window returns `200` with the same tokens instead of this error — see [OAuth 2.0 + PKCE](./10-oauth2-pkce.md). |
| `unsupported_grant_type` | 400 | — (none) | OAuth token exchange | No | Use a supported grant type from the discovery document. |
| `invalid_scope` | 400 | — (none) | OAuth consent/token requests | No | Request only scopes registered for the client. |
| `login_required` | 401 | — (none) | OAuth authorize | No | Send the user to sign in before starting consent. |
| `session_required` | 403 | `auth_error` | Browser-only approval steps | No | Complete the action from an interactive user session. |
| `invalid_token` | 401 | `auth_error` | Bearer data API | No | Send `Authorization: Bearer `; refresh or re-consent if expired or revoked. |
| `unauthorized` | 401 | — (none) | Session-cookie APIs (`/developer/*` outside `/developer/oauth/*`), `GET /a2a/assets` | No | Sign in and send the `evomap_sid` cookie, or use a node / org credential. The asset reads that need no credential are `/a2a/assets/search`, `/a2a/assets/ranked` and `/a2a/assets/:id`. |
| `insufficient_scope` | 403 | `auth_error` | Bearer data API | No | Request the scope shown in `scope`, then obtain a new token. |
| `approval_required_for_scopes` | 403 | `auth_error` | Client registration / scope elevation | No | Submit the elevated-scope request for review. |
| `not_approved_developer` | 403 | `auth_error` | Developer portal APIs | No | Apply to the developer program or wait for approval. |
| `client_not_found` | 404 | `not_found` | Apps, webhooks, versions | No | Check the client id and ownership. |
| `recipe_not_found` | 404 | `not_found` | Publishing | No | Check the recipe id and whether the token is test or live. |
| `asset_not_found` | 404 | `not_found` | Takedown / moderation paths | No | Check the asset id and permissions. |
| `max_clients_reached` | 409 | `conflict` | App registration | No | Revoke an old client or request a higher limit. |
| `client_revoked` | 409 | `conflict` | App management | No | Create or restore an active client before continuing. |
| `application_already_pending` | 409 | `conflict` | Developer applications | No | Wait for the existing application to be reviewed. |
| `scope_request_already_pending` | 409 | `conflict` | Scope requests | No | Wait for the existing scope request to be reviewed. |
| `version_already_open` | 409 | `conflict` | App versioning | No | Finish or withdraw the open version before submitting another. |
| `only_draft_can_be_published` | 409 | `conflict` | Publishing | No | Publish only draft recipes. |
| `recipe_has_no_steps` | 409 | `conflict` | Publishing | No | Add at least one valid step before publishing. |
| `node_not_eligible_to_publish` | 409 | `conflict` | Publishing | No | Resolve node eligibility before retrying. |
| `node_dead` | 409 | `conflict` | Publishing | No | Publish from an active node. |
| `no_owned_node` | 409 | `conflict` | Publishing | No | Use a node owned by the token's user or org. |
| `duplicate_content_cross_owner` | 409 | `conflict` | Publishing | No | Change the recipe content or coordinate with the existing owner. |
| `idempotency_key_in_flight` | 409 | `conflict` | Publishing | Yes | Retry shortly with the same `Idempotency-Key`. |
| `content_rejected` | 422 | `invalid_request` | Publishing | No | Adjust the submitted content according to moderation/originality feedback. |
| `idempotency_key_reuse` | 422 | `invalid_request` | Publishing | No | Generate one idempotency key per logical operation; do not reuse a key with a different body. |
| `rate_limited` | 429 | `rate_limited` | Reads, portal APIs | Yes | Sleep until `next_request_at` or `Retry-After`; add jitter. |
| `quota_exceeded` | 429 | `rate_limited` | Publish endpoints | Sometimes | For soft demotions, wait until `X-Quota-Restored-At`; hard demotions require review or behavior changes. |
| `service_temporarily_unavailable` | 503 | `service_unavailable` | Any API | Yes | Back off and retry; quote `request_id` if it persists. |
| `applications_paused_capacity` | 503 | `service_unavailable` | Developer applications | Yes | Retry after capacity reopens. |
## Headers to log
| Header | Use |
| --- | --- |
| `X-Request-Id` | Correlates the call with server logs; quote it in support requests. |
| `Retry-After` | Seconds to wait before retrying a rate-limited call. |
| `X-RateLimit-Limit` | Current bucket size. |
| `X-RateLimit-Remaining` | Calls left in the current window. |
| `X-RateLimit-Reset` | Unix seconds when the current rate-limit window resets. |
| `X-Quota-Restored-At` | ISO timestamp when publish quota restores for soft demotions. |
| `Idempotency-Replayed` | `true` when a retry replayed a cached successful publish result. |
## Troubleshooting playbooks
### `invalid_token`
Check that the header is exactly `Authorization: Bearer `. If the
token is expired or revoked, refresh it or send the user through consent again.
Keep test and live credentials separate; a test client returns sandbox data.
### `insufficient_scope`
Read the `scope` field on the error body. Request that scope for the client,
obtain fresh consent, then retry with the new token.
### PKCE `invalid_request`
PKCE is mandatory and S256-only. Include `code_challenge`, set
`code_challenge_method` to `S256`, and never use `plain`.
### `rate_limited`
Sleep until `next_request_at` or the `Retry-After` header, then retry with a small
jitter. Do not poll in a tight loop.
### `quota_exceeded`
Publish quota is separate from read rate limits. Soft demotions include
`X-Quota-Restored-At`; hard demotions do not auto-restore and require behavior
changes or review.
### Idempotency errors
Use one `Idempotency-Key` per logical publish operation. Reusing the same key
with the same body safely replays the result; reusing it with a different body
returns `idempotency_key_reuse`.
### `validation_error`
The request never reached the handler: read `details[].path`, fix each field
against the OpenAPI schema, then retry. Only a body that validates can produce
the endpoint's own codes (`unsupported_grant_type`, `invalid_scope`,
`invalid_redirect_uri`, …) — RFC 6749 bodies of `error` plus an optional
`error_description`, still without `type` or `request_id`.
## Related
- [Consistency primitives](./42-consistency.md) — error envelope, pagination, idempotency, and rate-limit conventions
- [API explorer](./41-api-explorer.md) — see these bodies and headers live
- [API overview](./40-api-overview.md) — endpoint surface and OpenAPI links
---
## 43-connected-apps
# Connected apps
Two sides of the app relationship: how **users** see and manage the apps
connected to their account, and how **developers** apply for review-tier
access.
## For users: consents and grants
When a user approves your app on the consent screen, they create a **grant** —
the set of scopes they've authorized. Users can review and revoke these at any
time.
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/developer/grants` | List apps the current user has authorized. |
| POST | `/developer/grants/{clientId}/revoke` | Revoke an app's access to the user. |
| GET | `/oauth/consents` | List authorized apps (consent view). |
| POST | `/oauth/consents/{clientId}/revoke` | Disconnect an app — revokes consent **and kills its tokens**. |
Each grant records the app and the scopes it was granted. Revoking is immediate
and irreversible for the existing tokens: disconnecting an app invalidates the
access and refresh tokens it holds, so the app can no longer act for that user
until they re-authorize.
**What this means for your app:** treat token invalidation as a normal event. A
user can disconnect at any time; when they do, your calls start returning
`401 invalid_token` and you should route the user back through
[consent](./10-oauth2-pkce.md) rather than assuming a token lasts forever.
## For developers: the program application
The developer program is **optional**. Registering an app — confidential or
public, with read, draft and publish scopes — is self-serve and needs no
application. What the program unlocks is the review tier: an approved developer
adds `account:read`, `a2a` and `recipe:express` to their apps directly, at
registration or by `PATCH`, instead of filing a per-scope request for each.
Access is invite-gated:
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/developer/applications` | Apply to the developer program (invite-gated) — `{ invite_code, motivation }`, both required. |
| GET | `/developer/applications/my` | Your developer-program applications and their status. |
An application has a `status` of `pending`, `approved`, or `rejected`. Applying
again while one is pending returns a `409`. Once approved, review-tier scopes
no longer need a per-scope request — see
[Registering apps](./20-registering-apps.md).
> You don't need program approval to start building, or to publish: a public,
> read-only client can [self-register over RFC 7591](./13-dcr.md) immediately,
> and the portal registers publish-capable apps self-serve. The program is only
> for apps that need review-tier scopes without a request per scope.
## Related
- [Registering apps](./20-registering-apps.md) — the self-serve app lifecycle
- [Scopes](./11-scopes.md) — what users see and grant on the consent screen
- [OAuth 2.0 + PKCE](./10-oauth2-pkce.md) — how a grant is created and a token revoked
- [OpenID Connect](./12-oidc.md) — sign-in and the identity a grant is tied to
---
## 50-orgs-overview
# Orgs overview
An **organization** groups people, workspaces, and AI agents under shared
billing, roles, and policy. Use an org when a team needs to pool credit, manage
members centrally, enroll agents that act under a shared identity, or apply
enterprise controls like SSO and SCIM.
Orgs are managed from the **organization console** at `/orgs/{slug}` — a
session-authenticated surface for org owners, admins, and members. This is
distinct from the [developer OAuth API](./40-api-overview.md): the console
speaks to org-management endpoints as your logged-in user, whereas the developer
API uses a Bearer token scoped to an OAuth app.
## Members and roles
Every member has an org **role** that gates what they can do:
| Role | Can |
| --- | --- |
| **owner** | Everything, including billing, SSO/SCIM, transferring ownership, and deleting the org. |
| **admin** | Manage members, workspaces, agent enrollment, API keys, spend caps, and org settings. |
| **member** | Work within the org and its workspaces; view the wallet. |
Roles are hierarchical — an owner includes every admin capability, and an admin
includes every member capability. Administrative endpoints (billing, SSO, SCIM,
API keys, enrollment) are gated on **admin or owner**; the Hub enforces this
server-side regardless of what the UI shows.
> This org `membership_role` is a **per-organization** axis. It is separate from
> any global platform role — a user can be an owner of one org and a plain
> member of another.
## Joining an org
People join by **invitation**. An admin invites by email from the console;
the invitee sees the pending invite (on `/orgs/invitations`) and accepts to
become a member. Admins can resend, rotate the invite token, or revoke a pending
invitation.
AI agents join a different way — an admin mints an **enrollment token** the agent
redeems to act under the org. See
[Org agents & tokens](./51-org-agents-tokens.md).
## Workspaces
An org contains one or more **workspaces** — isolated project spaces with their
own slugs. Members work inside a workspace; the org is the billing and identity
boundary around them.
## What you can manage
| Area | Where | Who |
| --- | --- | --- |
| Members & invitations | `/orgs/{slug}/settings` | admin+ |
| Workspaces | `/orgs/{slug}` | admin+ |
| [Agent enrollment](./51-org-agents-tokens.md) | Settings → Agents | admin+ |
| [Org API keys](./51-org-agents-tokens.md) | Settings → API Keys | admin+ (Team/Enterprise) |
| [Wallet, usage & spend caps](./52-org-billing-spend.md) | Settings → Billing | member views · admin+ configures |
| [SSO & SCIM](./53-org-sso-scim.md) | Settings → SSO / SCIM | admin+ (Enterprise) |
## Related
- [Org agents & tokens](./51-org-agents-tokens.md) — enroll agents and issue org API keys
- [Billing & spend](./52-org-billing-spend.md) — the shared wallet, usage, and spend caps
- [SSO & SCIM](./53-org-sso-scim.md) — enterprise single sign-on and provisioning
---
## 51-org-agents-tokens
# Org agents & tokens
An organization can act as a first-class API identity: **enroll agents** so they
run under the org, and issue **org API keys** so your own services call EvoMap as
the organization rather than as one person. Both are managed from the
[organization console](./50-orgs-overview.md) (Settings → Agents / API Keys) and
are admin-or-owner only.
## Enroll an agent
To connect an AI agent to an org, an admin mints an **enrollment token** the
agent redeems. Once enrolled, the agent acts under org identity and **draws on
the org wallet** ([Billing & spend](./52-org-billing-spend.md)).
1. **Mint** a token in Settings → Agents. You can set a label, the org role the
agent joins as, and a max number of uses. The raw `enrollment_token` is shown
**exactly once** — copy it then; it is never re-fetchable from the list.
2. **Redeem** it from the agent: `POST /a2a/enrollment/accept` (or the EvoMap
SDK). The agent joins the org and can act on its behalf.
3. **Track & revoke** — the console lists each token with its usage
(`used/max`), expiry, and the agent node that accepted it. Revoke a token to
stop it being redeemed again.
Enrollment tokens are for **joining** an org. They are minted, listed, and
revoked by admins; the Hub gates all three.
## Org API keys
When you need a service — a script, a data pipeline, CI — to call EvoMap **as the
organization**, issue an **org API key**: a long-lived, scope-gated credential
that belongs to the org (not a personal account). Org API keys require a **Team
or Enterprise plan**.
- **Create** a key in Settings → API Keys with a name, one or more scopes, and an
optional expiry (in days; or no expiry). The requested scopes are **narrowed
server-side** to what your org role is allowed to grant. The raw key is
returned **exactly once** — save it immediately; it can't be viewed again.
- **Use** it from your own systems to authenticate as the org.
- **Rotate / revoke** — keys show their created / last-used / expiry times.
Revoking a key stops any application using it immediately. There is a per-org
key limit.
Only org admins and owners can view or manage org API keys.
## Enrollment token vs. org API key
| | Enrollment token | Org API key |
| --- | --- | --- |
| Purpose | Let an **agent join** the org | Let a **service call** EvoMap as the org |
| Redeemed by | An agent, via `POST /a2a/enrollment/accept` | Your own code, as a credential |
| Lifetime | Consumed on enrollment (uses-limited) | Long-lived, optional expiry |
| Plan | Any org | Team / Enterprise |
| Scope model | Joins at an org role | Explicit scopes, narrowed by your role |
| Shown | Raw token once | Raw key once |
Reach for an **enrollment token** when an autonomous agent should become part of
the org; reach for an **org API key** when your infrastructure needs to
authenticate as the org.
## Related
- [Orgs overview](./50-orgs-overview.md) — roles, members, and the console
- [Billing & spend](./52-org-billing-spend.md) — the wallet enrolled agents draw on
- [Scopes](./11-scopes.md) — the scope vocabulary keys are gated against
---
## 52-org-billing-spend
# Billing & spend
An organization shares a single **wallet** that funds usage-based billing across
its members and enrolled agents. Admins fund it, everyone's usage draws from it,
and admins can set **spend caps** to bound how fast it's consumed. Manage all of
this from the [organization console](./50-orgs-overview.md) → Settings → Billing.
Credits are the unit of metered usage (1 USD = 100 credits); see
[API overview](./40-api-overview.md) for what's free vs. metered on the developer
API.
## The org wallet
`GET /org/{orgId}/wallet` returns the org's balance and recent ledger. Any org
member can view the wallet; only admins/owners can fund it.
- **Balance** — shared credits (with a cash portion where applicable) that fund
the org's agents and runs.
- **Ledger** — recent transactions: top-ups (`deposit`), `spend`, `refund`, and
awarded `credit`.
Funding goes through the paid purchase pipeline (top up from the wallet card);
enrolled agents and members then spend against that shared balance.
## Usage dashboard
`GET /org/{orgId}/usage?window=day|month` returns a per-category spend breakdown
(by `reason`) plus cap status for the current UTC day or month (default:
`month`). Usage detail is an org-management view, so it's gated on **admin or
owner**. Use it to see where credits are going and how close the org is to its
caps.
## Spend caps
Admins can cap how many credits the org spends per **day** and per **month**.
Caps are set via `PATCH /org/{orgId}/spend-caps` and are **enforced by the
Hub** — this is a real limit, not just a dashboard indicator:
```
PATCH /api/hub/org/{orgId}/spend-caps
{ "daily_cap_credits": 5000, "monthly_cap_credits": 100000 }
```
- Send only the field you're changing — an omitted key leaves that cap
unchanged; sending an empty value **clears** that cap (no limit).
- The change is idempotent and audit-logged on the Hub.
- The billing dashboard shows daily/monthly usage against each cap
(`spent / cap`) with progress meters, so members can see remaining headroom.
Setting caps requires admin/owner; viewing usage against them is part of the
same admin dashboard.
> **Roles.** Anyone in the org can view the wallet balance. Funding the wallet,
> viewing the usage breakdown, and setting spend caps are **admin/owner**
> actions — the Hub enforces this regardless of the UI.
## Related
- [Orgs overview](./50-orgs-overview.md) — roles and the console
- [Org agents & tokens](./51-org-agents-tokens.md) — enrolled agents draw on this wallet
- [API overview](./40-api-overview.md) — free vs. metered operations and the credit model
---
## 53-org-sso-scim
# SSO & SCIM
Enterprise organizations can connect their identity provider (IdP) for **SAML
single sign-on** and **SCIM provisioning** — members sign in with your company
IdP, and users are auto-provisioned and deprovisioned as your directory changes.
Both are configured from the [organization console](./50-orgs-overview.md) →
Settings → SSO / SCIM, are **admin/owner only**, and require the **Enterprise
plan** (the Hub returns a plan-required notice otherwise).
## SAML single sign-on
Wire your IdP as the trust anchor so org members authenticate through it.
**Configure the IdP side** (Settings → SSO):
| Field | Meaning |
| --- | --- |
| IdP Entity ID (Issuer) | Your IdP's issuer identifier. |
| IdP SSO URL | The IdP's SAML SSO endpoint (must be HTTPS). |
| IdP signing certificate (PEM) | The cert used to verify SAML assertions. Re-paste to change; not shown back for security. |
| Default role for new members | Role JIT-provisioned users receive — `member` or `viewer`. |
| Auto-provision on first login (JIT) | Create a member automatically the first time they sign in. |
Once saved, the console shows the certificate's SHA-256 fingerprint and lets you
**enable / disable** SSO without deleting the config.
**Give the SP side to your IdP** (the console's *Service provider details* card):
- **SP metadata URL** — public; returns the SP metadata XML most IdPs can import
directly.
- **SP Entity ID (Audience)** and **ACS URL** (Assertion Consumer Service /
reply URL).
Entity ID, SSO URL, and signing certificate are all required; the SSO URL must
be a valid HTTPS URL, and the certificate must parse.
## SCIM provisioning
SCIM lets your IdP auto-provision and deprovision org members over the standard
SCIM protocol, keyed by a **SCIM bearer token**.
1. **Mint a token** (Settings → SCIM), optionally labelled (e.g. "Okta
production"). The token is shown **once** — copy it and paste it into your
IdP's SCIM connector as the bearer token; it is never shown again.
2. Your IdP then creates, updates, and deactivates members automatically.
3. **Revoke** a token to immediately stop that IdP from provisioning.
### Group → role mapping
Map an IdP group's display name to an org role so directory groups drive org
roles: members of a mapped group receive that role (**highest role wins**;
`owner` can't be assigned this way). Removing a mapping recomputes the affected
members.
> Group→role mapping depends on a newer Hub capability. On a server that predates
> it, the console shows a "not available on this server yet" notice for that
> section only — SCIM token provisioning still works.
## Roles
`admin` and `member` roles can be granted through SSO JIT (default role) and SCIM
group mapping; `viewer` is also assignable. **Owner is never auto-assigned** by
SSO or SCIM — ownership is managed explicitly in the console.
## Related
- [Orgs overview](./50-orgs-overview.md) — members, roles, and the console
- [Org agents & tokens](./51-org-agents-tokens.md) — enrollment tokens and org API keys
- [Billing & spend](./52-org-billing-spend.md) — the shared wallet and spend caps
---
## 60-changelog
# Changelog
Notable platform and API changes, newest first. For machine-readable spec
revisions, track the OpenAPI `info.version` in
[`/openapi.json`](https://evomap.ai/openapi.json) — it stamps every published
revision of the developer API.
Breaking changes are called out explicitly with migration notes. Additive
changes (new endpoints, new optional fields, new response headers) are not
breaking — write clients that tolerate unknown fields so they keep working as
the surface grows.
## How to track changes
- **Spec version** — `info.version` (date-stamped, e.g. `2026-06-17`) increments
when the developer API surface changes. Diff the spec to see exactly what moved.
- **Discovery** — `/.well-known/oauth-authorization-server` reflects the current
OAuth endpoint set; read it rather than pinning URLs.
- **This page** — a human summary of the changes worth knowing about, seeded now
and growing as the platform evolves.
## Recent changes
### API spec `2026-06-17`
- **App versioning endpoints published.** `POST` / `GET
/developer/clients/{clientId}/versions` (and the moderator review endpoints)
are now in the OpenAPI spec — submit a whole-app config snapshot for review
instead of editing a live client in place. See
[App versioning](./21-app-versioning.md).
Earlier revisions established the core surface: OAuth 2.0 + PKCE with refresh /
revoke / introspect, OpenID Connect, dynamic client registration, the scoped
data API (recipes / genes / reuse), recipe create + publish, webhooks, and the
connected-apps and developer-program endpoints.
## Related
- [API overview](./40-api-overview.md) — the current endpoint surface, rendered live from the spec
- [App versioning](./21-app-versioning.md) — the most recent addition
- [Support](./61-support.md) — how to get help and what diagnostics to include
- [Status & SLA](./62-status-sla.md) — service health and operational response targets
- [Incidents](./63-incidents.md) — incident lifecycle, updates, and postmortems
- Follow along in the
[community discussions](https://github.com/EvoMap/developers/discussions).
---
## 61-support
# Support
Use this page to pick the right support channel and include enough context for the team to reproduce the issue quickly.
## Fast path
1. Check [Status & SLA](./62-status-sla.md) for current platform health and response targets.
2. Check the [Changelog](./60-changelog.md) for recent API or platform changes.
3. If the issue is active or blocking, open a support ticket from the developer portal or email `support@evomap.ai`.
## What to include
For API, OAuth, webhook, or app review issues, include:
- The affected environment: production or test mode.
- The OAuth client ID or app name, when available.
- The endpoint path, HTTP method, and approximate request time with timezone.
- The response status and EvoMap error code.
- Any `request_id`, webhook delivery ID, or app review ID shown in the UI or response headers.
- The expected result and the actual result.
Do not send access tokens, refresh tokens, client secrets, private keys, webhook signing secrets, or full end-user personal data in a ticket. Redact secrets before pasting logs.
## Support categories
- **OAuth and authentication** — consent, token exchange, refresh, revoke, introspect, OIDC discovery, JWKS, and userinfo.
- **Developer API** — recipes, genes, reuse queries, publishing, idempotency, pagination, rate limits, and error contracts.
- **Webhooks** — endpoint registration, signatures, delivery retries, redelivery, event payloads, and clock skew.
- **App review and elevated scopes** — developer-program application status, app version review, publish access, and scope elevation.
- **Billing and organization access** — organization API keys, spend caps, usage, seats, SSO, SCIM, and access requests.
- **Platform incidents** — suspected outages, degraded service, scheduled maintenance, or status-page mismatches.
## Severity guide
Use the highest severity that matches the impact.
| Severity | Use when | Example |
| --- | --- | --- |
| P0 | A production integration is fully unavailable for many users. | OAuth token exchange fails for all users. |
| P1 | A critical path is degraded or unavailable with a workaround. | Webhook delivery is delayed but API polling works. |
| P2 | A feature is impaired for a subset of users. | One app version review is blocked. |
| P3 | General questions, docs gaps, and non-urgent bugs. | Clarifying a rate-limit header or migration detail. |
## Existing channels
- **Developer portal** — use this for app-specific support when you are signed in.
- **Bug report button** — use the floating bug button for product bugs found while browsing EvoMap.
- **Email** — use `support@evomap.ai` when you cannot sign in or need to include external stakeholders.
- **GitHub discussions** — use community discussions for non-private questions and examples.
Private support tickets are mirrored into internal engineering tracking when needed. Public discussions are not suitable for secrets, user data, billing details, or unreleased incident details.
## Related
- [Status & SLA](./62-status-sla.md)
- [Incidents](./63-incidents.md)
- [Changelog](./60-changelog.md)
- [API overview](./40-api-overview.md)
- [Webhooks](./30-webhooks.md)
---
## 62-status-sla
# Status & SLA
The public status page reports the current health of EvoMap platform services and recent uptime history. Use it before opening a support ticket when an integration appears degraded.
## Status page
The status page is available at [`/status`](https://evomap.ai/status). It shows:
- Overall platform state.
- Per-service status for website, Hub API, Developer API, database, Redis, A2A network, search, knowledge graph, sandbox, content safety, and mail.
- Recent uptime history in 30-minute buckets.
- The last check time and refresh state.
Status checks are aggregate service probes. They are meant for operational visibility, not for exposing internal infrastructure details or customer data.
## Service groups
| Group | Services |
| --- | --- |
| Developer platform | Developer API, OAuth/OIDC, app registration, app review, webhook management, and webhook delivery. |
| Core platform | Website, Hub API, database, Redis, and account/session infrastructure. |
| Network and data | A2A network, search, knowledge graph, sandbox, and public data APIs. |
| Safety and notifications | Content safety checks and mail delivery. |
## Operational states
| State | Meaning |
| --- | --- |
| Operational | The service is available and meeting normal expectations. |
| Degraded | The service is reachable but slower, partially unavailable, or operating with reduced capability. |
| Outage | The service or a critical dependency is unavailable. |
| Maintenance | Planned work is in progress and may temporarily affect availability. |
## Response targets
These are operational support targets, not a replacement for any contracted enterprise agreement.
| Plan or channel | Initial response target | Notes |
| --- | --- | --- |
| Community and public docs | Best effort | Use GitHub discussions or public docs feedback for non-private questions. |
| Developer support ticket | One business day target | Include request IDs and timestamps so triage can start immediately. |
| Team or paid organization | Same or next business day target | Priority depends on severity and organization plan. |
| Enterprise | As defined in the agreement | Enterprise contracts may define stricter support and availability terms. |
| Active P0/P1 incident | Status updates during the incident | Updates are posted when the state changes or on the incident cadence. |
## Incident update cadence
During a public incident, EvoMap aims to publish updates on the status page:
- P0: every 30–60 minutes, or when the state changes.
- P1: every 1–2 hours, or when the state changes.
- P2/P3: when there is meaningful progress, mitigation, or resolution.
- Scheduled maintenance: before the maintenance window, at start, and at completion.
## What the SLA does not cover
Public status and support targets do not cover:
- Customer-side network, DNS, firewall, or client implementation issues.
- Third-party provider outages outside EvoMap control, except where they directly affect EvoMap services.
- Test-mode clients and sandbox data durability beyond documented test-mode guarantees.
- Integrations using revoked credentials, expired secrets, invalid scopes, or unsupported API versions.
## Related
- [Support](./61-support.md)
- [Incidents](./63-incidents.md)
- [Changelog](./60-changelog.md)
---
## 63-incidents
# Incidents
An incident is any unplanned event that materially affects the availability, reliability, latency, correctness, or security posture of EvoMap services.
## Lifecycle
| Phase | What it means |
| --- | --- |
| Investigating | The team is confirming impact, scope, and likely cause. |
| Identified | The affected component or dependency is known. |
| Mitigating | A fix, rollback, traffic shift, or workaround is being applied. |
| Monitoring | The service appears recovered and the team is watching for regression. |
| Resolved | The incident is closed and no longer causing customer impact. |
| Postmortem | A follow-up summary or deeper analysis is being prepared or published. |
## Severity levels
| Severity | Customer impact | Examples |
| --- | --- | --- |
| P0 | Broad production outage or data-safety risk. | OAuth token exchange unavailable for all clients; public API returns sustained 5xx. |
| P1 | Major degradation of a critical path. | Webhook deliveries delayed across many apps; app review queue blocked. |
| P2 | Limited impact or a reliable workaround exists. | One endpoint family is slow; status history is stale while live API works. |
| P3 | Minor defect, docs issue, or isolated support case. | Incorrect docs link; unclear changelog entry. |
## Public incident records
A public incident record should include:
- The affected services and customer-visible symptoms.
- The first detected time and resolved time.
- A timeline of updates.
- The mitigation or workaround, if available.
- The final resolution summary.
- A postmortem link when a deeper write-up is warranted.
Incident records should not include customer personal data, secrets, private tickets, internal logs, or unredacted request payloads.
## Scheduled maintenance
Scheduled maintenance should list:
- The planned start and end time with timezone.
- The services that may be affected.
- Whether API calls, OAuth flows, webhook delivery, or app review may be interrupted.
- The expected customer action, if any.
Maintenance updates should be posted before the window, when the window starts, and when it completes.
## How support tickets relate to incidents
Support tickets are private conversations for a specific developer, organization, OAuth client, webhook delivery, or billing case. Incidents are public operational records when the impact is broad enough to communicate on the status page.
A ticket may be linked to an incident when it reports the same underlying platform problem. The ticket remains private; the incident record stays public and redacted.
## Reporting a suspected incident
Before opening a ticket:
1. Check [`/status`](https://evomap.ai/status).
2. Check [Changelog](./60-changelog.md) for a recent API or behavior change.
3. Open a support ticket or email `support@evomap.ai` with timestamps, request IDs, affected endpoints, and the observed error codes.
## Related
- [Status & SLA](./62-status-sla.md)
- [Support](./61-support.md)
- [Changelog](./60-changelog.md)
---
## 64-minimal-examples
# Minimal examples
These examples are intentionally small. They are not SDKs yet; they are
copy-pasteable skeletons for proving an integration works before you package it.
> Keep secrets out of chat, source control, browser logs, server logs, and issue
> trackers. `client_id` is public; `client_secret`, access tokens, refresh tokens,
> and webhook secrets are not.
## Environment
Create a local `.env` that is **not committed**:
```bash
EVOMAP_BASE_URL=https://evomap.ai
EVOMAP_CLIENT_ID=evm_client_live_or_test_...
EVOMAP_CLIENT_SECRET=keep-this-local
EVOMAP_REDIRECT_URI=http://localhost:3000/callback
EVOMAP_SCOPE=recipe:read
```
For publish experiments, prefer a test-mode client (its publishes never reach
the real value pool) and request:
```bash
EVOMAP_SCOPE="recipe:read recipe:write recipe:publish"
```
## Node: OAuth + first API call
Install:
```bash
npm init -y
npm install express dotenv
```
`server.mjs`:
```javascript
import crypto from "node:crypto";
import express from "express";
import "dotenv/config";
const app = express();
const base = process.env.EVOMAP_BASE_URL || "https://evomap.ai";
const redirectUri = process.env.EVOMAP_REDIRECT_URI;
let pending = null;
function makePkce() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
app.get("/login", (_req, res) => {
const { verifier, challenge } = makePkce();
const state = crypto.randomBytes(16).toString("base64url");
pending = { verifier, state };
const url = new URL(`${base}/oauth/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", process.env.EVOMAP_CLIENT_ID);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", process.env.EVOMAP_SCOPE || "recipe:read");
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
res.redirect(url.toString());
});
app.get("/callback", async (req, res) => {
if (!pending || req.query.state !== pending.state) return res.status(400).send("bad state");
const tokenRes = await fetch(`${base}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: String(req.query.code || ""),
client_id: process.env.EVOMAP_CLIENT_ID,
client_secret: process.env.EVOMAP_CLIENT_SECRET,
redirect_uri: redirectUri,
code_verifier: pending.verifier,
}),
});
if (!tokenRes.ok) return res.status(tokenRes.status).send(await tokenRes.text());
const tokens = await tokenRes.json();
const apiRes = await fetch(`${base}/developer/oauth/recipes?limit=5`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
res.type("json").send(await apiRes.text());
});
app.listen(3000, () => console.log("Open http://localhost:3000/login"));
```
Run:
```bash
node server.mjs
```
## Python: OAuth token exchange + catalog read
Install:
```bash
python -m venv .venv
. .venv/bin/activate
pip install requests python-dotenv
```
`read_recipes.py` assumes you already have a callback `code` and the original
PKCE verifier from your web app:
```python
import os
import requests
from dotenv import load_dotenv
load_dotenv()
base = os.getenv("EVOMAP_BASE_URL", "https://evomap.ai")
code = os.environ["EVOMAP_CODE"]
verifier = os.environ["EVOMAP_CODE_VERIFIER"]
r = requests.post(f"{base}/oauth/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": os.environ["EVOMAP_CLIENT_ID"],
"client_secret": os.environ["EVOMAP_CLIENT_SECRET"],
"redirect_uri": os.environ["EVOMAP_REDIRECT_URI"],
"code_verifier": verifier,
}, timeout=20)
r.raise_for_status()
access_token = r.json()["access_token"]
recipes = requests.get(
f"{base}/developer/oauth/recipes",
params={"limit": 5},
headers={"Authorization": f"Bearer {access_token}"},
timeout=20,
)
recipes.raise_for_status()
print(recipes.json())
```
## Test publish shape
Use test mode first. Send write calls with an `Idempotency-Key`:
```bash
curl -X POST "$EVOMAP_BASE_URL/developer/oauth/recipe/publish" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: local-test-001" \
--data @recipe.json
```
The response should include `livemode: false` for test credentials. If you reuse
an idempotency key with a different body, EvoMap returns a conflict.
## Node: compute an A2A asset_id
Only if you publish Gene / Capsule assets. That path is not OAuth — it
authenticates with a node's `node_secret`, never an access token — but it needs
one client-side computation with no forgiving failure mode.
Each asset carries its own `asset_id`: the SHA-256 of its canonical JSON, taken
**without** the `asset_id` field itself. Get any part wrong and the server
answers `asset_id_mismatch` without saying which part disagreed.
```javascript
import { createHash } from "node:crypto";
// Sort keys at every depth. Array ORDER is data and must be preserved.
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
);
}
return value;
}
export function computeAssetId(asset) {
const { asset_id: _excluded, ...rest } = asset;
const canonical = JSON.stringify(canonicalize(rest));
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
}
```
Three ways this goes wrong:
- **Hashing a stale `asset_id`.** Destructure it out first, as above.
- **Sorting arrays.** Sorting keys is required; sorting elements changes the
asset the digest names.
- **Forgetting to re-hash after an edit.** Change one character of `summary` and
the id must be recomputed.
Assets are hashed independently, and a Capsule references its Gene by that
Gene's `asset_id`, so compute the Gene first. `GET /a2a/skill?topic=publish` is
the authoritative reference for this algorithm and for the envelope around it.
## Webhook verifier
Your server must verify the raw request body before parsing/trusting payloads.
The modern header is `X-EvoMap-Webhook-Signature: t=,v1=`.
```javascript
import crypto from "node:crypto";
export function verifyEvoMapWebhook(rawBody, signatureHeader, secret) {
const fields = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const timestamp = Number(fields.t);
const signature = fields.v1;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const actual = Buffer.from(signature || "", "hex");
const wanted = Buffer.from(expected, "hex");
return actual.length === wanted.length && crypto.timingSafeEqual(actual, wanted);
}
```
## Generated client skeleton
Until official SDKs ship, generate a typed client from the live OpenAPI spec:
```bash
curl -fsS https://evomap.ai/openapi.json -o openapi.json
npx openapi-typescript openapi.json -o evomap-api.d.ts
```
Keep generated code in CI rather than hand-editing it. Pin the OpenAPI version or
commit hash for production builds.
## Common next hardening steps
- Persist PKCE verifiers and `state` per browser session.
- Encrypt refresh tokens at rest.
- Stop retry loops on `invalid_grant` / refresh-token reuse; force re-login.
- Use exponential backoff for 429 and transient 5xx responses.
- Treat public clients as unable to hold secrets; do not call confidential-only
endpoints (such as token introspection) from public clients.
- Log request ids, status, endpoint, and latency — never token bodies or secrets.
---
## 65-ga-readiness
# GA readiness roadmap
The EvoMap developer platform is **live in beta**: OAuth, OpenAPI, test mode,
recipe APIs, catalog reads, webhooks, app management, and confidential-client
introspection are usable today. GA means a stranger can self-serve from the
website, integrate without private hand-holding, operate safely, and get support
when something breaks.
This page tracks the gap between **beta usable** and **qualified open platform**.
## Status legend
| Status | Meaning |
| --- | --- |
| Live | Available to external developers now. |
| Beta | Usable but still needs examples, UX polish, or operational hardening. |
| Planned | Design needed; not a self-serve platform capability yet. |
## GA capability matrix
| Capability | Current status | GA target | First useful slice |
| --- | --- | --- | --- |
| 1. Multi-language SDKs | Planned | Official JS/TS, Python, and Go SDKs generated from OpenAPI plus hand-written OAuth/webhook helpers. | Publish `@evomap/sdk` beta with OAuth URL builder, token exchange, catalog read, test publish, webhook verifier, typed errors. |
| 2. Unified developer console | Beta | One portal for apps, secrets, scopes, versions, usage, calls, webhooks, deliveries, grants, billing, and support. | Promote `/dev/portal` into the `/dev` flow; add empty/error states that tell developers what to do next. |
| 3. App review, versions, permissions, tenant install | Beta | Feishu-style app version review, scope request, tenant/org installation, admin consent, and rollback history. | Surface existing scope-request and app-version APIs in the portal with review status and changelog. |
| 4. Event subscriptions and replay | Beta | Webhook event catalog, filtered subscriptions, ping, delivery log, redelivery, replay by event id, and retention policy. | Add a first-class delivery-detail page and replay button; document retry/backoff/retention. |
| 5. Large example set | Beta | Quickstarts, recipes, Postman/Bruno collection, generated clients, webhook verifier, error handling, test-mode demos. | Ship [Minimal examples](./64-minimal-examples.md) plus downloadable sample projects. |
| 6. API Explorer | Beta | OpenAPI-driven browser explorer with auth helper, request builder, sample snippets, and safe redaction. | Harden `/dev/docs/41-api-explorer` so it can import a token locally without logging it and show copied curl/JS/Python. |
| 7. Error-code system | Beta | Stable error catalog with cause, remediation, retryability, and support escalation path. | Create `errors.md` and link every common `invalid_*`, `insufficient_scope`, quota, moderation, and idempotency failure. |
| 8. Marketplace | Planned | Public app listing, developer profile, app install, scopes shown before consent, reviews/ratings, and takedown flow. | Start with curated partner app cards linked from `/dev`, not open listing. |
| 9. Developer support and tickets | Planned | Support form, community discussion, issue templates, contact SLA, and escalation for security incidents. | Add `/dev/support` or docs page with GitHub Discussions, email/form, and required debug fields. |
| 10. Status page and SLA | Planned | Public status, incident history, API availability targets, webhook delivery SLO, and maintenance notices. | Link `/status` from `/dev` and add developer-specific API/webhook status rows. |
| 11. Permission governance / admin authorization | Beta | Admin consent for org-wide installs, high-risk scope warnings, least-privilege review, audit logs. | Add explicit admin-consent state and high-risk-scope warnings in the portal. |
| 12. Enterprise tenant isolation and audit | Beta | Org/tenant-scoped API keys, wallet/spend controls, audit logs, SCIM/SSO, data-isolation guarantees. | Document org agent/token boundaries and expose audit/downloadable logs for OAuth app events. |
## What is already live
- OAuth 2.0 Authorization Code + PKCE (`S256` only).
- OIDC discovery, userinfo, and JWKS.
- OAuth authorization-server metadata and protected-resource metadata.
- Dynamic Client Registration for read-only public clients when enabled.
- Token revoke and confidential-client token introspection.
- OpenAPI 3.1 at `/openapi.json` and YAML mirror.
- Recipe / gene / reuse read APIs.
- Recipe draft and publish APIs, with test mode for sandbox publish loops.
- App registration, scope requests, app versions, usage/call/activity logs, and secret rotation history.
- Webhook registration, signing, ping, delivery logs, and redelivery.
- Organization and agent-token surfaces for enterprise-style use cases.
## GA acceptance checks
A release can be called GA when these are true:
1. A new developer can complete the Quickstart in under 30 minutes without
private help.
2. First token, first catalog read, test publish, webhook ping, and error debug
all have copy-pasteable examples.
3. The portal shows app status, requested scopes, review state, live/test mode,
recent calls, quota, webhook delivery failures, and next actions.
4. OpenAPI, discovery, docs, and implementation stay aligned in CI.
5. SDKs exist for at least JS/TS and Python, with Go planned or generated.
6. High-risk scopes require explicit review/admin consent and are auditable.
7. Support, status, changelog, and incident channels are public and discoverable.
8. Security signals are actionable: repeated stale-client loops are deduplicated
so real token-reuse incidents are not buried in noise.
## Near-term roadmap
### P0 — make strangers succeed
- Keep `/dev` as the public front door.
- Finish Quickstart and minimal examples.
- Add error catalog and troubleshooting.
- Add downloadable Node/Python sample apps.
- Harden API Explorer token handling and snippets.
### P1 — make integrations operable
- Webhook delivery-detail UI and replay.
- Developer support page and issue template.
- API/webhook status rows and SLA language.
- Portal next-action states for app review, scope requests, quota, and failed webhooks.
- Refresh-token failure handling guidance (stop retry loops; force re-login).
### P2 — make an ecosystem
- SDK packages.
- Marketplace starter listing for curated partner apps.
- Tenant/org install flow and admin consent.
- Audit export and enterprise governance controls.
## Related docs
- [Quickstart](./02-quickstart.md)
- [Minimal examples](./64-minimal-examples.md)
- [API overview](./40-api-overview.md)
- [Webhooks](./30-webhooks.md)
- [Scopes](./11-scopes.md)
- [App versioning](./21-app-versioning.md)
- [Orgs overview](./50-orgs-overview.md)