# 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)